mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Add ability to upload and apply custom logos to social media posts
Implement functionality for uploading, applying, and deleting custom logos as overlays on images, along with backend processing and UI updates. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 91999614-5187-4902-b7a1-bf9e372de9c8 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/JgqpaNl
This commit is contained in:
1 parent
6ee2b61dd9
commit
225e4f2292
6 files changed
+498
-257
No files matched your search
@@ -35,19 +35,7 @@ interface Logo {
|
||||
enabled: boolean;
|
||||
opacity: number;
|
||||
position: "north_east" | "south_east" | "north_west" | "south_west" | "center";
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
saturation: number;
|
||||
blur: number;
|
||||
sharpen: number;
|
||||
vibrance: number;
|
||||
hue: number;
|
||||
gamma: number;
|
||||
vignette: number;
|
||||
effect: "none" | "grayscale" | "sepia" | "vintage" | "incognito" | "athena" | "audrey";
|
||||
size: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
export default function ImageEditor() {
|
||||
@@ -72,20 +60,8 @@ export default function ImageEditor() {
|
||||
const [logo, setLogo] = useState<Logo>({
|
||||
enabled: false,
|
||||
opacity: 60,
|
||||
position: "south_east"
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
blur: 0,
|
||||
sharpen: 0,
|
||||
vibrance: 0,
|
||||
hue: 0,
|
||||
gamma: 0,
|
||||
vignette: 0,
|
||||
effect: "none"
|
||||
position: "south_east",
|
||||
size: "medium"
|
||||
});
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState<string>("");
|
||||
@@ -94,58 +70,30 @@ export default function ImageEditor() {
|
||||
queryKey: ["/api/media"],
|
||||
});
|
||||
|
||||
const { data: cloudinaryConfig } = useQuery({
|
||||
queryKey: ['/api/cloudinary/config'],
|
||||
});
|
||||
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Save edited image mutation - DOM capture with html-to-image
|
||||
// Save edited image mutation - Server-side processing with Sharp
|
||||
const saveImageMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedMedia || !previewRef.current) {
|
||||
if (!selectedMedia) {
|
||||
throw new Error("Aucune image sélectionnée");
|
||||
}
|
||||
|
||||
// Capture preview exactly as displayed (no scaling)
|
||||
const dataUrl = await toPng(previewRef.current, {
|
||||
quality: 0.95,
|
||||
pixelRatio: 1, // Keep 1:1 - no zoom
|
||||
});
|
||||
// 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,
|
||||
};
|
||||
|
||||
// Convert data URL to blob
|
||||
const response = await fetch(dataUrl);
|
||||
let blob = await response.blob();
|
||||
|
||||
// Compress if needed to stay under Cloudinary limit
|
||||
if (blob.size > 8 * 1024 * 1024) {
|
||||
const img = new Image();
|
||||
img.src = dataUrl;
|
||||
await new Promise((resolve) => { img.onload = resolve; });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
blob = await new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob((b) => resolve(b!), 'image/jpeg', 0.8);
|
||||
});
|
||||
}
|
||||
|
||||
// Upload
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, `edited_${Date.now()}.jpg`);
|
||||
|
||||
const uploadResponse = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error("Erreur lors de l'upload");
|
||||
}
|
||||
|
||||
return uploadResponse.json();
|
||||
const response = await apiRequest('POST', '/api/media/apply-overlays', payload);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
|
||||
@@ -192,80 +140,18 @@ export default function ImageEditor() {
|
||||
// Base resize
|
||||
transformations.push('w_800,h_800,c_fit');
|
||||
|
||||
// Filters
|
||||
if (filters.brightness !== 0) {
|
||||
transformations.push(`e_brightness:${filters.brightness}`);
|
||||
}
|
||||
if (filters.contrast !== 0) {
|
||||
transformations.push(`e_contrast:${filters.contrast}`);
|
||||
}
|
||||
if (filters.saturation !== 0) {
|
||||
transformations.push(`e_saturation:${filters.saturation}`);
|
||||
}
|
||||
if (filters.blur > 0) {
|
||||
transformations.push(`e_blur:${filters.blur * 100}`);
|
||||
}
|
||||
if (filters.sharpen > 0) {
|
||||
transformations.push(`e_sharpen:${filters.sharpen * 100}`);
|
||||
}
|
||||
if (filters.vibrance !== 0) {
|
||||
transformations.push(`e_vibrance:${filters.vibrance}`);
|
||||
}
|
||||
if (filters.hue !== 0) {
|
||||
transformations.push(`e_hue:${filters.hue}`);
|
||||
}
|
||||
if (filters.gamma !== 0) {
|
||||
transformations.push(`e_gamma:${filters.gamma}`);
|
||||
}
|
||||
if (filters.vignette > 0) {
|
||||
transformations.push(`e_vignette:${filters.vignette}`);
|
||||
}
|
||||
if (filters.effect !== "none") {
|
||||
// Map effects to proper Cloudinary transformations
|
||||
switch(filters.effect) {
|
||||
case "grayscale":
|
||||
transformations.push("e_grayscale");
|
||||
break;
|
||||
case "sepia":
|
||||
transformations.push("e_sepia");
|
||||
break;
|
||||
case "vintage":
|
||||
// Vintage effect: combine sepia + vignette + reduced saturation
|
||||
transformations.push("e_sepia:50");
|
||||
transformations.push("e_vignette:30");
|
||||
transformations.push("e_saturation:-20");
|
||||
break;
|
||||
case "incognito":
|
||||
transformations.push("e_art:incognito");
|
||||
break;
|
||||
case "athena":
|
||||
transformations.push("e_art:athena");
|
||||
break;
|
||||
case "audrey":
|
||||
transformations.push("e_art:audrey");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Ribbon and price badge overlays are rendered as CSS elements in preview
|
||||
// They are NOT added to Cloudinary transformation URL
|
||||
// This allows for better visual control and positioning
|
||||
|
||||
// Logo overlay - using uploaded logo if available
|
||||
if (logo.enabled) {
|
||||
// TODO: Implement logo upload/selection system
|
||||
// For now, use a watermark text
|
||||
transformations.push(
|
||||
`l_text:Arial_32:© LOGO,co_white,o_${logo.opacity}`,
|
||||
`fl_layer_apply,g_${logo.position},x_30,y_30`
|
||||
);
|
||||
}
|
||||
// Logo overlay will be added by backend when saving
|
||||
// Preview just shows the base image with CSS overlays
|
||||
|
||||
// Construct final URL
|
||||
const transformedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/${transformations.join('/')}/${imagePath}`;
|
||||
setPreviewUrl(transformedUrl);
|
||||
|
||||
}, [selectedMedia, ribbon, priceBadge, logo, filters]);
|
||||
}, [selectedMedia, ribbon, priceBadge, logo]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
@@ -596,134 +482,72 @@ export default function ImageEditor() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filters */}
|
||||
{/* Logo */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filtres</CardTitle>
|
||||
<CardTitle>Logo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label>Luminosité</Label>
|
||||
<span className="text-sm text-muted-foreground">{filters.brightness}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[filters.brightness]}
|
||||
onValueChange={([value]) => setFilters({ ...filters, brightness: value })}
|
||||
min={-50}
|
||||
max={50}
|
||||
step={5}
|
||||
data-testid="slider-brightness"
|
||||
<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>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label>Contraste</Label>
|
||||
<span className="text-sm text-muted-foreground">{filters.contrast}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[filters.contrast]}
|
||||
onValueChange={([value]) => setFilters({ ...filters, contrast: value })}
|
||||
min={-50}
|
||||
max={50}
|
||||
step={5}
|
||||
data-testid="slider-contrast"
|
||||
/>
|
||||
</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">
|
||||
<div className="flex justify-between">
|
||||
<Label>Saturation</Label>
|
||||
<span className="text-sm text-muted-foreground">{filters.saturation}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[filters.saturation]}
|
||||
onValueChange={([value]) => setFilters({ ...filters, saturation: value })}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={10}
|
||||
data-testid="slider-saturation"
|
||||
/>
|
||||
</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">
|
||||
<Label>Vibrance</Label>
|
||||
<span className="text-sm text-muted-foreground">{filters.vibrance}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[filters.vibrance]}
|
||||
onValueChange={([value]) => setFilters({ ...filters, vibrance: value })}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={10}
|
||||
data-testid="slider-vibrance"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label>Teinte</Label>
|
||||
<span className="text-sm text-muted-foreground">{filters.hue}°</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[filters.hue]}
|
||||
onValueChange={([value]) => setFilters({ ...filters, hue: value })}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={10}
|
||||
data-testid="slider-hue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label>Gamma</Label>
|
||||
<span className="text-sm text-muted-foreground">{filters.gamma}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[filters.gamma]}
|
||||
onValueChange={([value]) => setFilters({ ...filters, gamma: value })}
|
||||
min={-50}
|
||||
max={150}
|
||||
step={10}
|
||||
data-testid="slider-gamma"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label>Vignette</Label>
|
||||
<span className="text-sm text-muted-foreground">{filters.vignette}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[filters.vignette]}
|
||||
onValueChange={([value]) => setFilters({ ...filters, vignette: value })}
|
||||
min={0}
|
||||
max={100}
|
||||
step={10}
|
||||
data-testid="slider-vignette"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Effet artistique</Label>
|
||||
<Select value={filters.effect} onValueChange={(value) => setFilters({ ...filters, effect: value as any })}>
|
||||
<SelectTrigger data-testid="select-effect">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Aucun</SelectItem>
|
||||
<SelectItem value="grayscale">Noir et blanc</SelectItem>
|
||||
<SelectItem value="sepia">Sépia</SelectItem>
|
||||
<SelectItem value="vintage">Vintage</SelectItem>
|
||||
<SelectItem value="incognito">Incognito</SelectItem>
|
||||
<SelectItem value="athena">Athena</SelectItem>
|
||||
<SelectItem value="audrey">Audrey</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>
|
||||
@@ -794,6 +618,26 @@ export default function ImageEditor() {
|
||||
{priceBadge.price}€
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logo Overlay (CSS) */}
|
||||
{logo.enabled && cloudinaryConfig && (cloudinaryConfig as any).logoPublicId && (
|
||||
<img
|
||||
src={`https://res.cloudinary.com/${(cloudinaryConfig as any).cloudName}/image/upload/${(cloudinaryConfig as any).logoPublicId}`}
|
||||
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 ${
|
||||
logo.size === "small" ? "w-16" :
|
||||
logo.size === "large" ? "w-32" :
|
||||
"w-24"
|
||||
}`}
|
||||
style={{ opacity: logo.opacity / 100 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain } from "lucide-react";
|
||||
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X } from "lucide-react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
@@ -23,6 +23,8 @@ export default function Settings() {
|
||||
const [openrouterApiKey, setOpenrouterApiKey] = useState("");
|
||||
const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-3.5-sonnet");
|
||||
const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: cloudinaryConfig } = useQuery({
|
||||
@@ -41,6 +43,13 @@ export default function Settings() {
|
||||
if (cloudinaryConfig) {
|
||||
setCloudName((cloudinaryConfig as any).cloudName || "");
|
||||
setApiKey((cloudinaryConfig as any).apiKey || "");
|
||||
|
||||
// Set logo preview if exists
|
||||
if ((cloudinaryConfig as any).logoPublicId) {
|
||||
const cloudName = (cloudinaryConfig as any).cloudName;
|
||||
const logoPublicId = (cloudinaryConfig as any).logoPublicId;
|
||||
setLogoPreview(`https://res.cloudinary.com/${cloudName}/image/upload/${logoPublicId}`);
|
||||
}
|
||||
}
|
||||
}, [cloudinaryConfig]);
|
||||
|
||||
@@ -119,6 +128,82 @@ export default function Settings() {
|
||||
},
|
||||
});
|
||||
|
||||
const uploadLogoMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
|
||||
const response = await fetch('/api/cloudinary/logo', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to upload logo');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
|
||||
toast({
|
||||
title: "Logo uploadé",
|
||||
description: "Votre logo a été enregistré avec succès",
|
||||
});
|
||||
setLogoFile(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Impossible d'uploader le logo",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteLogoMutation = useMutation({
|
||||
mutationFn: () => apiRequest('DELETE', '/api/cloudinary/logo', {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
|
||||
setLogoPreview(null);
|
||||
setLogoFile(null);
|
||||
toast({
|
||||
title: "Logo supprimé",
|
||||
description: "Votre logo a été supprimé avec succès",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer le logo",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Veuillez sélectionner une image",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLogoFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setLogoPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
@@ -331,6 +416,104 @@ export default function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Image className="w-5 h-5" />
|
||||
Logo de l'entreprise
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Uploadez votre logo pour l'ajouter aux images éditées
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{logoPreview ? (
|
||||
<div className="space-y-4">
|
||||
<div className="relative w-full max-w-xs mx-auto">
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Logo"
|
||||
className="w-full h-auto max-h-48 object-contain rounded-lg border-2 border-border"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
className="absolute top-2 right-2 h-8 w-8"
|
||||
onClick={() => deleteLogoMutation.mutate()}
|
||||
disabled={deleteLogoMutation.isPending}
|
||||
data-testid="button-delete-logo"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Label htmlFor="logo-upload-replace" className="cursor-pointer">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={uploadLogoMutation.isPending}
|
||||
data-testid="button-replace-logo"
|
||||
onClick={() => document.getElementById('logo-upload-replace')?.click()}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadLogoMutation.isPending ? "Upload en cours..." : "Remplacer le logo"}
|
||||
</Button>
|
||||
</Label>
|
||||
<input
|
||||
id="logo-upload-replace"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleLogoChange(e);
|
||||
if (e.target.files?.[0]) {
|
||||
uploadLogoMutation.mutate(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center">
|
||||
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Aucun logo uploadé. Choisissez une image PNG ou JPG.
|
||||
</p>
|
||||
<Label htmlFor="logo-upload" className="cursor-pointer">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={uploadLogoMutation.isPending || !cloudinaryConfig}
|
||||
data-testid="button-upload-logo"
|
||||
onClick={() => document.getElementById('logo-upload')?.click()}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadLogoMutation.isPending ? "Upload en cours..." : "Uploader un logo"}
|
||||
</Button>
|
||||
</Label>
|
||||
<input
|
||||
id="logo-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleLogoChange(e);
|
||||
if (e.target.files?.[0]) {
|
||||
uploadLogoMutation.mutate(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!cloudinaryConfig && (
|
||||
<p className="text-xs text-orange-600 dark:text-orange-400 text-center">
|
||||
Veuillez d'abord configurer Cloudinary ci-dessus
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
|
||||
Reference in new issue
Block a user