mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-14 18:58:30 +02:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ADkrVcVFTT14hbB148HYS
625 lines
27 KiB
TypeScript
625 lines
27 KiB
TypeScript
import { useState, useEffect, useRef } from "react";
|
|
import { Page } from "@/components/layout/app-shell";
|
|
import { PageHeader } from "@/components/layout/page-header";
|
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
|
import { toPng } from 'html-to-image';
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Wand2, Save, Image as ImageIcon, X } from "lucide-react";
|
|
import { Link } from "wouter";
|
|
import { EmptyState } from "@/components/empty-state";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
interface Ribbon {
|
|
enabled: boolean;
|
|
text: "Promo" | "Nouveauté" | "Arrivage" | "Stock limité";
|
|
color: "red" | "yellow";
|
|
position: "north_west" | "north_east";
|
|
}
|
|
|
|
interface PriceBadge {
|
|
enabled: boolean;
|
|
price: string;
|
|
color: "red" | "yellow";
|
|
position: "north_east" | "south_east" | "north_west" | "south_west";
|
|
size: number; // Font size in pixels
|
|
}
|
|
|
|
interface Logo {
|
|
enabled: boolean;
|
|
opacity: number;
|
|
position: "north_east" | "south_east" | "north_west" | "south_west" | "center";
|
|
size: "small" | "medium" | "large";
|
|
}
|
|
|
|
export default function ImageEditor() {
|
|
const [selectedMedia, setSelectedMedia] = useState<any>(null);
|
|
|
|
const [ribbon, setRibbon] = useState<Ribbon>({
|
|
enabled: false,
|
|
text: "Promo",
|
|
color: "red",
|
|
position: "north_west"
|
|
});
|
|
|
|
const [priceBadge, setPriceBadge] = useState<PriceBadge>({
|
|
enabled: false,
|
|
price: "",
|
|
color: "red",
|
|
position: "north_east",
|
|
size: 20 // Default font size
|
|
});
|
|
|
|
const [logo, setLogo] = useState<Logo>({
|
|
enabled: false,
|
|
opacity: 60,
|
|
position: "south_east",
|
|
size: "medium"
|
|
});
|
|
|
|
const [previewUrl, setPreviewUrl] = useState<string>("");
|
|
|
|
const { data: mediaList } = useQuery({
|
|
queryKey: ["/api/media"],
|
|
});
|
|
|
|
const { data: cloudinaryConfig } = useQuery({
|
|
queryKey: ['/api/cloudinary/config'],
|
|
});
|
|
|
|
const previewRef = useRef<HTMLDivElement>(null);
|
|
const { toast } = useToast();
|
|
|
|
// Save edited image mutation - Server-side processing with Sharp
|
|
const saveImageMutation = useMutation({
|
|
mutationFn: async () => {
|
|
if (!selectedMedia) {
|
|
throw new Error("Aucune image sélectionnée");
|
|
}
|
|
|
|
// Send image URL and overlay parameters to backend
|
|
const payload = {
|
|
imageUrl: selectedMedia.originalUrl,
|
|
ribbon: ribbon.enabled ? ribbon : undefined,
|
|
priceBadge: priceBadge.enabled && priceBadge.price ? priceBadge : undefined,
|
|
logo: logo.enabled ? logo : undefined,
|
|
};
|
|
|
|
const response = await apiRequest('POST', '/api/media/apply-overlays', payload);
|
|
return response;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
|
|
toast({
|
|
title: "Image enregistrée !",
|
|
description: "L'image éditée a été ajoutée à votre médiathèque.",
|
|
});
|
|
},
|
|
onError: (error: Error) => {
|
|
toast({
|
|
title: "Erreur",
|
|
description: error.message || "Impossible d'enregistrer l'image.",
|
|
variant: "destructive"
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
// Set preview URL from selected media
|
|
useEffect(() => {
|
|
if (!selectedMedia) { setPreviewUrl(""); return; }
|
|
setPreviewUrl(selectedMedia.originalUrl);
|
|
}, [selectedMedia, ribbon, priceBadge, logo]);
|
|
|
|
return (
|
|
<Page width="wide">
|
|
<style>{`
|
|
/* Triangle ribbon - auto-centered text */
|
|
.ribbon-container {
|
|
position: absolute;
|
|
z-index: 10;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.ribbon-container.north_west {
|
|
top: 0;
|
|
left: 0;
|
|
width: 140px;
|
|
height: 140px;
|
|
}
|
|
|
|
.ribbon-container.north_east {
|
|
top: 0;
|
|
right: 0;
|
|
width: 140px;
|
|
height: 140px;
|
|
}
|
|
|
|
.ribbon-triangle {
|
|
position: absolute;
|
|
width: 0;
|
|
height: 0;
|
|
border-style: solid;
|
|
}
|
|
|
|
.ribbon-container.north_west .ribbon-triangle {
|
|
top: 0;
|
|
left: 0;
|
|
border-width: 140px 140px 0 0;
|
|
}
|
|
|
|
.ribbon-container.north_west .ribbon-triangle.red {
|
|
border-top-color: #FF0000;
|
|
border-right-color: transparent;
|
|
border-bottom-color: transparent;
|
|
border-left-color: transparent;
|
|
}
|
|
|
|
.ribbon-container.north_west .ribbon-triangle.yellow {
|
|
border-top-color: #FFC107;
|
|
border-right-color: transparent;
|
|
border-bottom-color: transparent;
|
|
border-left-color: transparent;
|
|
}
|
|
|
|
.ribbon-container.north_east .ribbon-triangle {
|
|
top: 0;
|
|
right: 0;
|
|
border-width: 0 140px 140px 0;
|
|
}
|
|
|
|
.ribbon-container.north_east .ribbon-triangle.red {
|
|
border-top-color: transparent;
|
|
border-right-color: #FF0000;
|
|
border-bottom-color: transparent;
|
|
border-left-color: transparent;
|
|
}
|
|
|
|
.ribbon-container.north_east .ribbon-triangle.yellow {
|
|
border-top-color: transparent;
|
|
border-right-color: #FFC107;
|
|
border-bottom-color: transparent;
|
|
border-left-color: transparent;
|
|
}
|
|
|
|
.ribbon-text {
|
|
position: absolute;
|
|
color: white;
|
|
font-weight: bold;
|
|
z-index: 11;
|
|
text-transform: uppercase;
|
|
text-align: center;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.ribbon-container.north_west .ribbon-text {
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%) rotate(-45deg) translateY(-20px);
|
|
}
|
|
|
|
.ribbon-container.north_east .ribbon-text {
|
|
top: 50%;
|
|
right: 50%;
|
|
transform: translate(50%, -50%) rotate(45deg) translateY(-20px);
|
|
}
|
|
`}</style>
|
|
|
|
<PageHeader
|
|
icon={Wand2}
|
|
title="Éditeur d'images"
|
|
description="Ajoutez un ruban, un prix ou votre logo sur une image, puis enregistrez-la dans la médiathèque."
|
|
/>
|
|
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]">
|
|
{/* Left Column - Controls */}
|
|
<div className="space-y-6">
|
|
{/* Image Selection */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>1. Choisissez une image</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{mediaList && (mediaList as any[]).filter((m: any) => m.type === 'image').length > 0 ? (
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3 max-h-[300px] overflow-y-auto">
|
|
{(mediaList as any[])
|
|
.filter((m: any) => m.type === 'image')
|
|
.map((media: any) => (
|
|
<div
|
|
key={media.id}
|
|
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
|
|
selectedMedia?.id === media.id ? 'border-primary ring-2 ring-primary/30' : 'border-transparent hover:border-primary/50'
|
|
}`}
|
|
onClick={() => setSelectedMedia(media)}
|
|
data-testid={`select-image-${media.id}`}
|
|
>
|
|
<img
|
|
src={media.instagramFeedUrl || media.originalUrl}
|
|
alt=""
|
|
className="w-full h-24 object-cover"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
compact
|
|
icon={ImageIcon}
|
|
title="Aucune image disponible"
|
|
description="Importez d'abord des images dans la médiathèque."
|
|
action={
|
|
<Button asChild size="sm">
|
|
<Link href="/media">Ouvrir la médiathèque</Link>
|
|
</Button>
|
|
}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Ribbon Controls */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>2. Ruban promotionnel</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="ribbon-enabled"
|
|
checked={ribbon.enabled}
|
|
onCheckedChange={(checked) => setRibbon({ ...ribbon, enabled: checked as boolean })}
|
|
data-testid="checkbox-ribbon"
|
|
/>
|
|
<Label htmlFor="ribbon-enabled">Afficher un ruban</Label>
|
|
</div>
|
|
|
|
{ribbon.enabled && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label>Texte du ruban</Label>
|
|
<RadioGroup
|
|
value={ribbon.text}
|
|
onValueChange={(value) => setRibbon({ ...ribbon, text: value as any })}
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="Promo" id="ribbon-promo" data-testid="radio-ribbon-promo" />
|
|
<Label htmlFor="ribbon-promo">Promo</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="Nouveauté" id="ribbon-nouveaute" data-testid="radio-ribbon-nouveaute" />
|
|
<Label htmlFor="ribbon-nouveaute">Nouveauté</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="Arrivage" id="ribbon-arrivage" data-testid="radio-ribbon-arrivage" />
|
|
<Label htmlFor="ribbon-arrivage">Arrivage</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="Stock limité" id="ribbon-stock" data-testid="radio-ribbon-stock" />
|
|
<Label htmlFor="ribbon-stock">Stock limité</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Couleur</Label>
|
|
<RadioGroup
|
|
value={ribbon.color}
|
|
onValueChange={(value) => setRibbon({ ...ribbon, color: value as any })}
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="red" id="ribbon-red" data-testid="radio-ribbon-red" />
|
|
<Label htmlFor="ribbon-red" className="flex items-center gap-2">
|
|
Rouge <Badge className="bg-red-600">Exemple</Badge>
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="yellow" id="ribbon-yellow" data-testid="radio-ribbon-yellow" />
|
|
<Label htmlFor="ribbon-yellow" className="flex items-center gap-2">
|
|
Jaune <Badge className="bg-yellow-500">Exemple</Badge>
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Position</Label>
|
|
<Select value={ribbon.position} onValueChange={(value) => setRibbon({ ...ribbon, position: value as any })}>
|
|
<SelectTrigger data-testid="select-ribbon-position">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="north_west">Haut gauche</SelectItem>
|
|
<SelectItem value="north_east">Haut droite</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Price Badge */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>3. Prix</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="price-enabled"
|
|
checked={priceBadge.enabled}
|
|
onCheckedChange={(checked) => setPriceBadge({ ...priceBadge, enabled: checked as boolean })}
|
|
data-testid="checkbox-price"
|
|
/>
|
|
<Label htmlFor="price-enabled">Afficher le prix</Label>
|
|
</div>
|
|
|
|
{priceBadge.enabled && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="price-input">Prix (€)</Label>
|
|
<Input
|
|
id="price-input"
|
|
type="text"
|
|
placeholder="19.99"
|
|
value={priceBadge.price}
|
|
onChange={(e) => setPriceBadge({ ...priceBadge, price: e.target.value })}
|
|
data-testid="input-price"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Couleur</Label>
|
|
<RadioGroup
|
|
value={priceBadge.color}
|
|
onValueChange={(value) => setPriceBadge({ ...priceBadge, color: value as any })}
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="red" id="price-red" data-testid="radio-price-red" />
|
|
<Label htmlFor="price-red" className="flex items-center gap-2">
|
|
Rouge <Badge className="bg-red-600">19.99€</Badge>
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="yellow" id="price-yellow" data-testid="radio-price-yellow" />
|
|
<Label htmlFor="price-yellow" className="flex items-center gap-2">
|
|
Jaune <Badge className="bg-yellow-500">19.99€</Badge>
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Position</Label>
|
|
<Select value={priceBadge.position} onValueChange={(value) => setPriceBadge({ ...priceBadge, position: value as any })}>
|
|
<SelectTrigger data-testid="select-price-position">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="north_east">Haut droite</SelectItem>
|
|
<SelectItem value="north_west">Haut gauche</SelectItem>
|
|
<SelectItem value="south_east">Bas droite</SelectItem>
|
|
<SelectItem value="south_west">Bas gauche</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<Label>Taille du texte</Label>
|
|
<span className="text-sm text-muted-foreground">{priceBadge.size}px</span>
|
|
</div>
|
|
<Slider
|
|
value={[priceBadge.size]}
|
|
onValueChange={(value) => setPriceBadge({ ...priceBadge, size: value[0] })}
|
|
min={12}
|
|
max={70}
|
|
step={1}
|
|
data-testid="slider-price-size"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Logo */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>4. Logo</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="logo-enabled"
|
|
checked={logo.enabled}
|
|
onCheckedChange={(checked) => setLogo({ ...logo, enabled: checked as boolean })}
|
|
data-testid="checkbox-logo-enabled"
|
|
/>
|
|
<Label htmlFor="logo-enabled" className="cursor-pointer">
|
|
Afficher le logo
|
|
</Label>
|
|
</div>
|
|
|
|
{logo.enabled && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label>Position</Label>
|
|
<Select value={logo.position} onValueChange={(value) => setLogo({ ...logo, position: value as any })}>
|
|
<SelectTrigger data-testid="select-logo-position">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="north_west">Haut gauche</SelectItem>
|
|
<SelectItem value="north_east">Haut droit</SelectItem>
|
|
<SelectItem value="south_west">Bas gauche</SelectItem>
|
|
<SelectItem value="south_east">Bas droit</SelectItem>
|
|
<SelectItem value="center">Centre</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Taille</Label>
|
|
<Select value={logo.size} onValueChange={(value) => setLogo({ ...logo, size: value as any })}>
|
|
<SelectTrigger data-testid="select-logo-size">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="small">Petit</SelectItem>
|
|
<SelectItem value="medium">Moyen</SelectItem>
|
|
<SelectItem value="large">Grand</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<Label>Opacité</Label>
|
|
<span className="text-sm text-muted-foreground">{logo.opacity}%</span>
|
|
</div>
|
|
<Slider
|
|
value={[logo.opacity]}
|
|
onValueChange={(value) => setLogo({ ...logo, opacity: value[0] })}
|
|
min={0}
|
|
max={100}
|
|
step={5}
|
|
data-testid="slider-logo-opacity"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Right Column - Preview */}
|
|
<div className="lg:sticky lg:top-6 lg:h-fit">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Aperçu</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{selectedMedia ? (
|
|
<div className="space-y-4">
|
|
<div className="relative rounded-lg overflow-hidden bg-muted aspect-square flex items-center justify-center">
|
|
<div ref={previewRef} className="relative">
|
|
<img
|
|
src={previewUrl || selectedMedia.originalUrl}
|
|
alt="Aperçu"
|
|
className="max-w-full max-h-[706px] w-auto h-auto"
|
|
data-testid="preview-image"
|
|
/>
|
|
|
|
{/* CSS Triangle Ribbon Overlay - Auto-centered */}
|
|
{ribbon.enabled && ribbon.text && (
|
|
<div className={`ribbon-container ${ribbon.position}`}>
|
|
<div className={`ribbon-triangle ${ribbon.color}`} />
|
|
<div
|
|
className="ribbon-text"
|
|
style={{
|
|
fontSize: ribbon.text === "Stock limité" ? '20px' :
|
|
ribbon.text.length <= 5 ? '28px' :
|
|
ribbon.text.length <= 8 ? '24px' :
|
|
ribbon.text.length <= 11 ? '20px' : '16px',
|
|
lineHeight: ribbon.text === "Stock limité" ? '1.2' : 'normal',
|
|
whiteSpace: ribbon.text === "Stock limité" ? 'normal' : 'nowrap',
|
|
transform: ribbon.text === "Stock limité"
|
|
? (ribbon.position === "north_west"
|
|
? 'translate(-50%, -50%) rotate(-45deg) translateY(-35px) translateX(2px)'
|
|
: 'translate(50%, -50%) rotate(45deg) translateY(-35px) translateX(-2px)')
|
|
: undefined
|
|
}}
|
|
>
|
|
{ribbon.text === "Stock limité" ? (
|
|
<>
|
|
Stock
|
|
<br />
|
|
limité
|
|
</>
|
|
) : ribbon.text}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Price Badge Overlay (CSS) */}
|
|
{priceBadge.enabled && priceBadge.price && (
|
|
<div
|
|
data-badge-price="true"
|
|
className={`absolute ${
|
|
priceBadge.position === "north_east" ? "top-4 right-4" :
|
|
priceBadge.position === "south_east" ? "bottom-4 right-4" :
|
|
priceBadge.position === "south_west" ? "bottom-4 left-4" :
|
|
"top-4 left-4"
|
|
} px-4 py-2 rounded-full text-white font-bold z-10 flex items-center justify-center ${
|
|
priceBadge.color === "red" ? "bg-red-600" : "bg-yellow-500"
|
|
}`}
|
|
style={{ fontSize: `${priceBadge.size}px` }}
|
|
>
|
|
{priceBadge.price} €
|
|
</div>
|
|
)}
|
|
|
|
{/* Logo Overlay (CSS) */}
|
|
{logo.enabled && cloudinaryConfig && (cloudinaryConfig as any).logoUrl && (
|
|
<img
|
|
src={(cloudinaryConfig as any).logoUrl}
|
|
alt="Logo"
|
|
className={`absolute ${
|
|
logo.position === "north_east" ? "top-4 right-4" :
|
|
logo.position === "south_east" ? "bottom-4 right-4" :
|
|
logo.position === "south_west" ? "bottom-4 left-4" :
|
|
logo.position === "center" ? "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" :
|
|
"top-4 left-4"
|
|
} z-10 max-w-full max-h-full object-contain`}
|
|
style={{
|
|
opacity: logo.opacity / 100,
|
|
width: logo.size === "small" ? "35%" : logo.size === "large" ? "70%" : "50%"
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setSelectedMedia(null)}
|
|
className="absolute top-2 right-2 p-1.5 bg-background/80 backdrop-blur hover:bg-background rounded-full text-foreground shadow transition-colors z-20"
|
|
data-testid="button-clear-selection"
|
|
title="Désélectionner l'image"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<Button
|
|
className="w-full"
|
|
variant="brand"
|
|
data-testid="button-save"
|
|
onClick={() => saveImageMutation.mutate()}
|
|
disabled={saveImageMutation.isPending}
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
{saveImageMutation.isPending ? "Enregistrement…" : "Enregistrer dans la médiathèque"}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="aspect-square flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed bg-muted/40 text-sm text-muted-foreground">
|
|
<ImageIcon className="h-8 w-8" />
|
|
Choisissez une image pour voir l'aperçu
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</Page>
|
|
);
|
|
}
|