ldc-ovoteam/resources/ts/back/Components/Inputs/ImageCropper.tsx
Jérémy Munsch ca9c0fbc4e
feat(policy): allow ProductDto and RecipeDto in policies
Updated ProductPolicy and RecipePolicy to accept ProductDto in addition to Product. This change enhances the flexibility of the policies by allowing DTOs to be used for authorization checks.

refactor(provider): simplify service provider bindings

Refactored AppServiceProvider to simplify the binding of ProductDirectory and RecipeDirectory. Removed unnecessary dependencies and improved clarity.

feat(route): update route bindings for front-end

Changed route bindings for products and recipes to use frontProduct and frontRecipe. This improves clarity and consistency in route handling.

fix(breadcrumbs): update breadcrumb checks for DTOs

Updated breadcrumb generation to check for ProductDto and RecipeDto instead of their model counterparts. This ensures breadcrumbs function correctly with the new DTO structure.

refactor(types): remove FilePath type and update schemas

Removed the FilePath type definition and updated related schemas to use string directly. This simplifies type handling for file paths.

style(entities): standardize naming conventions

Updated naming conventions in Product and Recipe entities to use snake_case for consistency across the codebase.

feat(frontend): update product and recipe pages for new DTO structure

Modified front-end components to accommodate changes in the product and recipe data structures, ensuring compatibility with the new DTOs.
2026-11-05 16:49:42 +01:00

1025 lines
32 KiB
TypeScript

import { usePage } from "@inertiajs/react";
import {
ChevronDownIcon,
CropIcon,
FlipHorizontalIcon,
FlipVerticalIcon,
RefreshCwIcon,
RotateCcwIcon,
RotateCwIcon,
Trash2Icon,
Upload,
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react";
import {
useEffect,
useRef,
useState,
type Dispatch,
type MouseEvent,
type SetStateAction,
type SyntheticEvent,
} from "react";
import { type FileWithPath } from "react-dropzone";
import ReactCrop, { type Crop, type PixelCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import type { ConfigBackend } from "@/back/types/global";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Slider } from "@/components/ui/slider";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import useDeviceType from "@/hooks/use-device-type";
import str from "@/hooks/use-string";
import trans from "@/hooks/use-translations";
import { cn } from "@/lib/utils";
interface CropValidationRules {
width: number;
height: number;
}
type FileWithPreview = FileWithPath & {
preview: string;
};
interface ImageCropperProps {
dialogOpen: boolean;
name: string;
value?: string | File | null;
selectedFile: FileWithPreview | null;
validationRules: CropValidationRules;
isReset: boolean;
url: string;
finalFileName: string;
setData: (name: string, value: File | string | null) => void;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
reset: (...fields: string[]) => void;
setIsReset: (value: boolean) => void;
}
export default function ImageCropper({
dialogOpen,
setDialogOpen,
selectedFile,
validationRules,
setData,
name,
value,
reset,
setIsReset,
isReset,
url,
finalFileName,
}: Readonly<ImageCropperProps>) {
const { routes } = usePage().props.configBackend as ConfigBackend;
const { errors } = usePage().props;
const aspect = 1;
const { isDesktop } = useDeviceType();
const imgRef = useRef<HTMLImageElement | null>(null);
const isDuplicating = route().current(`${routes.name}*.duplicate`);
// Zone of the crop in %.
const [cropZone, setCropZone] = useState<Crop>();
const [croppedImage, setCroppedImage] = useState<string>("");
const [rotation, setRotation] = useState(0);
const [flipX, setFlipX] = useState(false);
const [flipY, setFlipY] = useState(false);
const [zoom, setZoom] = useState(0);
const [scaleFactor, setScaleFactor] = useState(1);
const [imagePosition, setImagePosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [originalDimensions, setOriginalDimensions] = useState({
width: 0,
height: 0,
});
const [prevSelectedFile, setPrevSelectedFile] = useState(selectedFile);
if (selectedFile !== prevSelectedFile) {
setPrevSelectedFile(selectedFile);
setImagePosition({ x: 0, y: 0 });
setOriginalDimensions({ width: 0, height: 0 });
}
// États pour les filtres
const [brightness, setBrightness] = useState(100);
const [contrast, setContrast] = useState(100);
const [saturation, setSaturation] = useState(100);
const [sepia, setSepia] = useState(0);
const [grayscale, setGrayscale] = useState(0);
const [filtersOpen, setFiltersOpen] = useState(false);
const maxContainerHeight = isDesktop ? 550 : 350;
const maxContainerWidth = isDesktop ? 900 : 450;
/**
* Generate imge from canvas with all crop options.
* @param image HTMLImageElement Ref. of the image tag.
* @param cropLocalization Crop localization in the image.
* @return string
*/
function getCroppedImg(image: HTMLImageElement, crop: PixelCrop): string {
const canvas = document.createElement("canvas");
canvas.width = validationRules.width;
canvas.height = validationRules.height;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("canvas context missing.");
}
// Calculer le ratio entre l'affichage et les dimensions originales
const displayScale = scaleFactor * (1 + zoom);
// Position de l'image affichée avec zoom
const displayedWidth = originalDimensions.width * displayScale;
const displayedHeight = originalDimensions.height * displayScale;
const offsetX =
imagePosition.x -
(displayedWidth - originalDimensions.width * scaleFactor) / 2;
const offsetY =
imagePosition.y -
(displayedHeight - originalDimensions.height * scaleFactor) / 2;
// Centre de l'image dans le container d'affichage
const imageCenterX = offsetX + displayedWidth / 2;
const imageCenterY = offsetY + displayedHeight / 2;
// Centre du crop dans le container d'affichage
const cropCenterX = crop.x + crop.width / 2;
const cropCenterY = crop.y + crop.height / 2;
// Distance du centre du crop au centre de l'image (en pixels d'affichage)
const deltaX = cropCenterX - imageCenterX;
const deltaY = cropCenterY - imageCenterY;
// Taille du canvas temporaire basée sur la résolution originale de la zone croppée
// On limite toutefois pour éviter des canvas trop grands (max 4x la taille finale)
const tmpWidth = Math.ceil(crop.width / displayScale);
const tmpHeight = Math.ceil(crop.height / displayScale);
// Créer un canvas temporaire à haute résolution
const tmpCanvas = document.createElement("canvas");
tmpCanvas.width = tmpWidth;
tmpCanvas.height = tmpHeight;
const tmpCtx = tmpCanvas.getContext("2d");
if (!tmpCtx) {
throw new Error("canvas context missing.");
}
tmpCtx.save();
// Appliquer les filtres
tmpCtx.filter = `brightness(${String(brightness)}%) contrast(${String(contrast)}%) saturate(${String(saturation)}%) sepia(${String(sepia)}%) grayscale(${String(grayscale)}%)`;
// Translater au centre du canvas temporaire
tmpCtx.translate(tmpWidth / 2, tmpHeight / 2);
// Appliquer rotation et flip
tmpCtx.rotate((rotation * Math.PI) / 180);
tmpCtx.scale(flipX ? -1 : 1, flipY ? -1 : 1);
// Calculer où dessiner l'image pour que la zone croppée soit centrée
// deltaX/deltaY sont en pixels d'affichage, on les convertit en pixels originaux
const originalDeltaX = deltaX / displayScale;
const originalDeltaY = deltaY / displayScale;
// Dessiner l'image originale à sa pleine résolution
tmpCtx.drawImage(
image,
-originalDimensions.width / 2 - originalDeltaX,
-originalDimensions.height / 2 - originalDeltaY,
originalDimensions.width,
originalDimensions.height,
);
tmpCtx.restore();
// Redimensionner vers la taille finale en une seule opération
ctx.drawImage(
tmpCanvas,
0,
0,
tmpWidth,
tmpHeight,
0,
0,
canvas.width,
canvas.height,
);
return canvas.toDataURL("image/png");
}
/**
* Generate base64 from an url.
* @param targetUrl string
* @param callback (base64: string | ArrayBuffer | null) => void
* @param errorCallback (error: string) => void
*/
function urlToBase64(
targetUrl: string,
callback: (base64: string | ArrayBuffer | null) => void,
errorCallback?: (error: string) => void,
): void {
fetch(targetUrl)
.then((response) => response.blob())
.then((blob) => {
const reader = new FileReader();
reader.onloadend = () => {
callback(reader.result);
};
reader.readAsDataURL(blob);
})
.catch((error: unknown) => {
if (errorCallback) errorCallback(String(error));
});
}
/**
* Generate file from the base64.
* @param dataUrl base64 string
* @param filename name of your file
* @return File
*/
function base64ToFile(dataUrl: string): File {
const [meta, base64] = dataUrl.split(",");
const mimeMatch = /:(.*?);/.exec(meta);
const mime = mimeMatch ? mimeMatch[1] : "image/png";
const decodedBase64 = atob(base64);
const { length } = decodedBase64;
const u8arr = new Uint8Array(length);
for (let i = 0; i < length; i += 1) {
u8arr[i] = decodedBase64.charCodeAt(i);
}
return new File([u8arr], finalFileName, { type: mime });
}
// Fonctions de gestion du drag
function handleMouseDown(e: MouseEvent) {
setIsDragging(true);
setDragStart({
x: e.clientX - imagePosition.x,
y: e.clientY - imagePosition.y,
});
}
function handleMouseMove(e: MouseEvent) {
if (!isDragging) return;
setImagePosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
});
}
function handleMouseUp() {
setIsDragging(false);
}
useEffect(() => {
if (!value || !isDuplicating) return;
const rawPath = typeof value === "object" ? value?.name : value;
if (!rawPath) return;
const pathFileBase = /^https?:\/\//i.test(rawPath)
? rawPath
: `${url}/${rawPath.replace(/^\/+/, "")}`;
urlToBase64(
pathFileBase,
(base64) => {
if (typeof base64 === "string") {
const file = base64ToFile(base64);
setData(name, file);
}
},
(err) => {
console.error(err);
},
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* Crop the image with all options.
* @return void
*/
function onCrop(): void {
try {
if (imgRef.current && cropZone) {
// Recalculer les coordonnées en pixels à partir de cropZone actuel
// cropZone est en % par rapport au container de ReactCrop
const cropX = (cropZone.x / 100) * maxContainerWidth;
const cropY = (cropZone.y / 100) * maxContainerHeight;
const cropWidth = (cropZone.width / 100) * maxContainerWidth;
const cropHeight = (cropZone.height / 100) * maxContainerHeight;
// Créer l'objet cropLocalization mis à jour
const currentCropLocalization: PixelCrop = {
unit: "px",
x: cropX,
y: cropY,
width: cropWidth,
height: cropHeight,
};
const finalImage = getCroppedImg(
imgRef.current,
currentCropLocalization,
);
setCroppedImage(finalImage);
const file = base64ToFile(finalImage);
if (!isReset) {
setData(name, file);
} else {
reset(name);
}
setDialogOpen(false);
}
} catch (error: unknown) {
throw new Error(`Something went wrong : ${String(error)}`);
}
}
/**
* Set the crop zone.
* @param width number
* @param height number
* @param currentScaleFactor number
*/
function placeCropZone(
width: number,
height: number,
currentScaleFactor: number,
): void {
const displayedWidth = width * currentScaleFactor;
const displayedHeight = height * currentScaleFactor;
// Calculer l'offset pour centrer l'image dans le container
const centerOffsetX = (maxContainerWidth - displayedWidth) / 2;
const centerOffsetY = (maxContainerHeight - displayedHeight) / 2;
// Initialiser la position centrée (seulement si imagePosition est à 0,0)
if (imagePosition.x === 0 && imagePosition.y === 0) {
setImagePosition({ x: centerOffsetX, y: centerOffsetY });
}
// Zone de crop simple : garder les dimensions d'origine, réduire seulement si ça dépasse le container
let finalCropWidth = validationRules.width;
let finalCropHeight = validationRules.height;
// Si la zone dépasse le container, la réduire proportionnellement
if (
finalCropWidth > maxContainerWidth * 0.9 ||
finalCropHeight > maxContainerHeight * 0.9
) {
const containerWidthRatio = (maxContainerWidth * 0.9) / finalCropWidth;
const containerHeightRatio = (maxContainerHeight * 0.9) / finalCropHeight;
const reductionRatio = Math.min(
containerWidthRatio,
containerHeightRatio,
);
finalCropWidth = validationRules.width * reductionRatio;
finalCropHeight = validationRules.height * reductionRatio;
}
// Convertir en pourcentage par rapport au CONTAINER (pas à l'image)
const cropWidthPercent = (finalCropWidth / maxContainerWidth) * 100;
const cropHeightPercent = (finalCropHeight / maxContainerHeight) * 100;
setCropZone(
cropZone ?? {
unit: "%",
width: cropWidthPercent,
height: cropHeightPercent,
x: (100 - cropWidthPercent) / 2, // Centrer horizontalement
y: (100 - cropHeightPercent) / 2, // Centrer verticalement
},
);
}
/**
* Load image when open the dialog.
* @param e SyntheticEvent<HTMLImageElement>
* @return void
*/
function onImageLoad(e: SyntheticEvent<HTMLImageElement>): void {
const { naturalWidth: width, naturalHeight: height } = e.currentTarget;
// STOCKER les dimensions naturelles originales
setOriginalDimensions({ width, height });
// Calculer le facteur d'échelle pour l'image
const imageWidthRatio = maxContainerWidth / width;
const imageHeightRatio = maxContainerHeight / height;
const imageScaleFactor = Math.min(imageWidthRatio, imageHeightRatio, 1); // Ne pas agrandir l'image
setScaleFactor(imageScaleFactor);
placeCropZone(width, height, imageScaleFactor);
}
/**
* Reset function to return to initial state.
* @return void
*/
function resetToInitialState(): void {
// Options reset.
setRotation(0);
setFlipX(false);
setFlipY(false);
setZoom(0);
setBrightness(100);
setContrast(100);
setSaturation(100);
setSepia(0);
setGrayscale(0);
if (value) setIsReset(true);
// Recalculer et forcer le centrage de l'image ET de la zone de crop
if (originalDimensions.width > 0) {
const displayedWidth = originalDimensions.width * scaleFactor;
const displayedHeight = originalDimensions.height * scaleFactor;
// Calculer l'offset pour centrer l'image dans le container
const centerOffsetX = (maxContainerWidth - displayedWidth) / 2;
const centerOffsetY = (maxContainerHeight - displayedHeight) / 2;
// Forcer la position centrée
setImagePosition({ x: centerOffsetX, y: centerOffsetY });
// IMPORTANT: Recalculer la zone de crop centrée
let finalCropWidth = validationRules.width;
let finalCropHeight = validationRules.height;
// Si la zone dépasse le container, la réduire proportionnellement
if (
finalCropWidth > maxContainerWidth * 0.9 ||
finalCropHeight > maxContainerHeight * 0.9
) {
const containerWidthRatio = (maxContainerWidth * 0.9) / finalCropWidth;
const containerHeightRatio =
(maxContainerHeight * 0.9) / finalCropHeight;
const reductionRatio = Math.min(
containerWidthRatio,
containerHeightRatio,
);
finalCropWidth = validationRules.width * reductionRatio;
finalCropHeight = validationRules.height * reductionRatio;
}
// Convertir en pourcentage et centrer
const cropWidthPercent = (finalCropWidth / maxContainerWidth) * 100;
const cropHeightPercent = (finalCropHeight / maxContainerHeight) * 100;
setCropZone({
unit: "%",
width: cropWidthPercent,
height: cropHeightPercent,
x: (100 - cropWidthPercent) / 2, // Centrer horizontalement
y: (100 - cropHeightPercent) / 2, // Centrer verticalement
});
} else {
// Si pas d'image, remettre à 0,0
setImagePosition({ x: 0, y: 0 });
}
}
// Translations.
const transBrightness = str(
trans("uwil-backend::back.image_cropper.filters.brightness"),
)
.ucFirst()
.value();
const transContrast = str(
trans("uwil-backend::back.image_cropper.filters.contrast"),
)
.ucFirst()
.value();
const transSaturation = str(
trans("uwil-backend::back.image_cropper.filters.saturation"),
)
.ucFirst()
.value();
const transSepia = str(
trans("uwil-backend::back.image_cropper.filters.sepia"),
)
.ucFirst()
.value();
const transGrayscale = str(
trans("uwil-backend::back.image_cropper.filters.grayscale"),
)
.ucFirst()
.value();
const transRotation = str(
trans("uwil-backend::back.image_cropper.filters.rotation"),
)
.ucFirst()
.value();
const transZoom = str(trans("uwil-backend::back.image_cropper.filters.zoom"))
.ucFirst()
.value();
// Filters (brightness, contrast, saturation, sepia, grayscale).
const imageFilterOptions = [
{
label: () => `${transBrightness}: ${String(brightness)}%`,
tooltip: "brightness",
slider: {
min: 0,
max: 200,
step: 1,
value: [brightness],
onValueChange: (e: number[]) => {
setIsReset(false);
setBrightness(parseInt(e[0].toString(), 10));
},
},
},
{
label: () => `${transContrast}: ${String(contrast)}%`,
tooltip: "contrast",
slider: {
min: 0,
max: 200,
step: 1,
value: [contrast],
onValueChange: (e: number[]) => {
setContrast(parseInt(e[0].toString(), 10));
setIsReset(false);
},
},
},
{
label: () => `${transSaturation}: ${String(saturation)}%`,
tooltip: "saturation",
slider: {
min: 0,
max: 200,
step: 1,
value: [saturation],
onValueChange: (e: number[]) => {
setSaturation(parseInt(e[0].toString(), 10));
setIsReset(false);
},
},
},
{
label: () => `${transSepia}: ${String(sepia)}%`,
tooltip: "sepia",
slider: {
min: 0,
max: 100,
step: 1,
value: [sepia],
onValueChange: (e: number[]) => {
setSepia(parseInt(e[0].toString(), 10));
setIsReset(false);
},
},
},
{
label: () => `${transGrayscale}: ${String(grayscale)}%`,
tooltip: "grayscale",
slider: {
min: 0,
max: 100,
step: 1,
value: [grayscale],
onValueChange: (e: number[]) => {
setGrayscale(parseInt(e[0].toString(), 10));
setIsReset(false);
},
},
},
];
// Main options (rotation, zoom).
const mainOptions = [
{
label: () => `${transRotation}: ${String(rotation)}°`,
tooltip: "rotation",
slider: {
min: -180,
max: 180,
step: 1,
value: [rotation],
onValueChange: (e: number[]) => {
setRotation(parseInt(e[0].toString(), 10));
setIsReset(false);
},
},
buttons: [
{
icon: RotateCcwIcon,
tooltip: "rotation_back",
fn: () => {
setRotation((r) => r - 10);
},
},
{
icon: RotateCwIcon,
tooltip: "rotation_forward",
fn: () => {
setRotation((r) => r + 10);
},
},
],
},
{
label: () => `${transZoom}: ${String(Math.round((1 + zoom) * 100))}%`,
tooltip: "zoom",
slider: {
min: -0.99,
max: 500,
step: 0.1,
value: [Math.round((1 + zoom) * 100)],
onValueChange: (e: number[]) => {
setZoom(e[0] / 100 - 1);
setIsReset(false);
},
},
buttons: [
{
icon: ZoomOutIcon,
tooltip: "zoomOut",
fn: () => {
setZoom((currentValue) => {
const newValue = currentValue - 0.1;
return newValue < -0.99 ? -0.99 : parseFloat(newValue.toFixed(1));
});
setIsReset(false);
},
},
{
icon: ZoomInIcon,
tooltip: "zoomIn",
fn: () => {
setZoom((currentValue) => {
const newValue = currentValue + 0.1;
return newValue > 4 ? 4 : parseFloat(newValue.toFixed(1));
});
setIsReset(false);
},
},
],
},
];
// Other options (reset, horizontal and vertical flip)
const otherOptions = [
{
icon: FlipHorizontalIcon,
fn: () => {
setFlipX((prev) => !prev);
},
tooltip: "horizontal_flip",
},
{
icon: FlipVerticalIcon,
fn: () => {
setFlipY((prev) => !prev);
},
tooltip: "vertical_flip",
},
{
icon: RefreshCwIcon,
fn: resetToInitialState,
tooltip: "reset",
},
];
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<div className="relative">
<DialogTrigger asChild>
<Button
variant="ghost"
type="button"
className={cn("flex border overflow-hidden w-full h-36", {
"border-destructive": errors[name],
})}
>
<img
className="w-full h-full object-contain"
src={croppedImage.length ? croppedImage : selectedFile?.preview}
alt={trans("uwil-backend::back.image_cropper.preview_image")}
/>
{!selectedFile && (
<div className="w-full h-full flex items-center justify-center">
<Upload />
</div>
)}
</Button>
</DialogTrigger>
</div>
<DialogContent className="overflow-y-scroll max-h-[90vh] w-full max-w-full md:max-w-xl lg:max-w-5xl xl:max-w-screen-xl">
<DialogTitle>
{trans("uwil-backend::back.image_cropper.modale_title")}
</DialogTitle>
<div className="flex flex-col xl:flex-row items-center gap-4">
{/* Cropper content */}
<div
className="w-full flex items-center"
style={{
height: maxContainerHeight,
width: maxContainerWidth,
}}
>
<ReactCrop
crop={cropZone}
onChange={(_, percentCrop) => {
setCropZone(percentCrop);
}}
aspect={aspect}
className="w-full"
locked
>
<div
role="presentation"
style={{
height: maxContainerHeight,
width: maxContainerWidth,
}}
className="relative overflow-hidden bg-gray-100"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseUp}
>
<img
ref={imgRef}
crossOrigin="anonymous"
src={selectedFile?.preview}
onLoad={onImageLoad}
alt={trans("uwil-backend::back.image_cropper.preview_image")}
style={{
position: "absolute",
left: `${String(imagePosition.x)}px`,
top: `${String(imagePosition.y)}px`,
width:
originalDimensions.width > 0
? `${String(originalDimensions.width * scaleFactor)}px`
: "auto",
height:
originalDimensions.height > 0
? `${String(originalDimensions.height * scaleFactor)}px`
: "auto",
transform: `scale(${String(1 + zoom)}) rotate(${String(rotation)}deg) scaleX(${String(flipX ? -1 : 1)}) scaleY(${String(flipY ? -1 : 1)})`,
filter: `brightness(${String(brightness)}%) contrast(${String(contrast)}%) saturate(${String(saturation)}%) sepia(${String(sepia)}%) grayscale(${String(grayscale)}%)`,
transformOrigin: "center center",
transition: !isDragging
? "transform 0.2s ease, filter 0.2s ease"
: "none",
cursor: isDragging ? "grabbing" : "grab",
userSelect: "none",
}}
draggable={false}
/>
</div>
</ReactCrop>
</div>
{/* Cropper filters */}
<div className="flex flex-col items-center justify-between gap-4 w-full h-full">
<div className="w-full">
<DialogDescription className="text-center mb-4">
{trans("uwil-backend::back.image_cropper.modale_description")}
</DialogDescription>
{mainOptions.map((option) => (
<div
key="option"
className="flex items-center gap-2 w-full mb-4"
>
<div className="flex flex-col w-full items-start">
<label htmlFor="rotation" className="mb-2 text-sm">
{option.label()}
</label>
<Tooltip>
<TooltipTrigger asChild>
<Slider
min={option.slider.min}
max={option.slider.max}
value={option.slider.value}
className="w-full"
onValueChange={option.slider.onValueChange}
/>
</TooltipTrigger>
<TooltipContent>
{str(
trans(
`uwil-backend::back.image_cropper.filters.${option.tooltip}`,
),
)
.ucFirst()
.value()}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1">
{option.buttons.map((currentOption) => (
<Tooltip key="currentOption">
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={currentOption.fn}
>
<currentOption.icon />
</Button>
</TooltipTrigger>
<TooltipContent>
{str(
trans(
`uwil-backend::back.image_cropper.filters.${currentOption.tooltip}`,
),
)
.ucFirst()
.value()}
</TooltipContent>
</Tooltip>
))}
</div>
</div>
))}
{/* FILTRES - Collapsible */}
<Collapsible
open={filtersOpen}
onOpenChange={setFiltersOpen}
className="w-full mb-4"
>
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between">
<span className="text-sm">
{str(
trans("uwil-backend::back.filter", {
choice: 2,
}),
)
.ucFirst()
.value()}
</span>
<ChevronDownIcon
className={`h-4 w-4 transition-transform duration-200 ${filtersOpen ? "rotate-180" : ""}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<div className="grid grid-cols-2 gap-4">
{imageFilterOptions.map((option) => (
<div
key="filterOption"
className="flex flex-col items-start"
>
<label
htmlFor={str(option.label()).slug().value()}
className="mb-2 text-sm"
>
{option.label()}
</label>
<Tooltip>
<TooltipTrigger asChild>
<Slider
id={str(option.label()).slug().value()}
min={option.slider.min}
max={option.slider.max}
value={option.slider.value}
className="w-full"
onValueChange={option.slider.onValueChange}
/>
</TooltipTrigger>
<TooltipContent>
{str(
trans(
`uwil-backend::back.image_cropper.filters.${option.tooltip}`,
),
)
.ucFirst()
.value()}
</TooltipContent>
</Tooltip>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
{/* Others actions */}
<div className="flex flex-row justify-center gap-2 mb-4">
{otherOptions.map((option) => {
return (
<Tooltip key="otherOption">
<TooltipTrigger asChild>
<Button variant="outline" size="sm" onClick={option.fn}>
<option.icon />
</Button>
</TooltipTrigger>
<TooltipContent>
{str(
trans(
`uwil-backend::back.image_cropper.filters.${option.tooltip}`,
),
)
.ucFirst()
.value()}
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
<DialogFooter className="justify-center">
<DialogClose asChild>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
type="reset"
className="w-fit"
variant="outline"
onClick={() => {
setDialogOpen(false);
}}
>
<Trash2Icon className="mr-1.5 size-4" />
{str(trans("uwil-backend::back.cancel"))
.ucFirst()
.value()}
</Button>
</TooltipTrigger>
<TooltipContent>
{str(
trans(
"uwil-backend::back.image_cropper.action_the_crop",
{
params: {
action: str(trans("uwil-backend::back.cancel"))
.ucFirst()
.value(),
},
},
),
)
.ucFirst()
.value()}
</TooltipContent>
</Tooltip>
</DialogClose>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="submit"
size="sm"
className="w-fit"
onClick={() => {
onCrop();
}}
>
<CropIcon className="mr-1.5 size-4" />
{str(trans("uwil-backend::back.crop")).ucFirst().value()}
</Button>
</TooltipTrigger>
<TooltipContent>
{str(
trans("uwil-backend::back.image_cropper.action_the_crop", {
params: {
action: str(trans("uwil-backend::back.validate"))
.ucFirst()
.value(),
},
}),
)
.ucFirst()
.value()}
</TooltipContent>
</Tooltip>
</DialogFooter>
</div>
</div>
</DialogContent>
</Dialog>
);
}