Merge pull request #62 from R0m1k3/dev

Dev
This commit is contained in:
LogiFlow authored and GitHub committed 2025-10-11 13:07:19 +02:00
commit 52ff983b7b
40 files changed
+1531 -13

No files matched your search

+5
View File
@@ -4,6 +4,7 @@ hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"]
[nix]
channel = "stable-24_05"
packages = ["cairo", "pango", "libuuid", "libjpeg", "giflib"]
[deployment]
deploymentTarget = "autoscale"
@@ -34,6 +35,10 @@ externalPort = 6800
localPort = 37431
externalPort = 5000
[[ports]]
localPort = 37567
externalPort = 9000
[[ports]]
localPort = 37659
externalPort = 4200
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

+2
View File
@@ -10,6 +10,7 @@ import Dashboard from "@/pages/dashboard";
import NewPost from "@/pages/new-post";
import Calendar from "@/pages/calendar";
import Media from "@/pages/media";
import ImageEditor from "@/pages/image-editor";
import PagesManagement from "@/pages/pages";
import AI from "@/pages/ai";
import History from "@/pages/history";
@@ -68,6 +69,7 @@ function Router() {
<Route path="/new">{() => <ProtectedRoute component={NewPost} />}</Route>
<Route path="/calendar">{() => <ProtectedRoute component={Calendar} />}</Route>
<Route path="/media">{() => <ProtectedRoute component={Media} />}</Route>
<Route path="/image-editor">{() => <ProtectedRoute component={ImageEditor} />}</Route>
<Route path="/pages">{() => <ProtectedRoute component={PagesManagement} />}</Route>
<Route path="/ai">{() => <ProtectedRoute component={AI} adminOnly />}</Route>
<Route path="/history">{() => <ProtectedRoute component={History} />}</Route>
+46 -12
View File
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useMutation, useQuery } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2 } from "lucide-react";
import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { SiFacebook, SiInstagram } from "react-icons/si";
@@ -12,6 +12,7 @@ export default function MediaUpload() {
const { toast } = useToast();
const [selectedFile, setSelectedFile] = useState<any>(null);
const [visibleCount, setVisibleCount] = useState(5);
const [zoomImage, setZoomImage] = useState<string | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const { data: mediaList } = useQuery({
@@ -167,18 +168,32 @@ export default function MediaUpload() {
className="w-full h-full object-cover"
/>
)}
<button
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate(media.id);
}}
className="absolute top-2 right-2 w-8 h-8 bg-black/70 hover:bg-destructive rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
data-testid={`button-delete-${media.id}`}
>
<X className="w-4 h-4 text-white" />
</button>
<div className="absolute top-2 right-2 flex gap-2">
{media.type === "image" && (
<button
onClick={(e) => {
e.stopPropagation();
setZoomImage(media.originalUrl);
}}
className="w-8 h-8 bg-black/70 hover:bg-primary rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
data-testid={`button-zoom-${media.id}`}
>
<ZoomIn className="w-4 h-4 text-white" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate(media.id);
}}
className="w-8 h-8 bg-black/70 hover:bg-destructive rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
data-testid={`button-delete-${media.id}`}
>
<X className="w-4 h-4 text-white" />
</button>
</div>
</div>
))}
) as any)}
</div>
{/* Élément sentinelle pour le scroll infini */}
@@ -264,6 +279,25 @@ export default function MediaUpload() {
</div>
</DialogContent>
</Dialog>
{/* Modal pour voir l'image originale en plein écran */}
<Dialog open={!!zoomImage} onOpenChange={() => setZoomImage(null)}>
<DialogContent className="max-w-7xl w-[95vw] h-[95vh] p-0 bg-black/95">
<div className="relative w-full h-full flex items-center justify-center">
<img
src={zoomImage || ""}
alt="Image originale"
className="max-w-full max-h-full object-contain"
/>
<button
onClick={() => setZoomImage(null)}
className="absolute top-4 right-4 w-10 h-10 bg-white/10 hover:bg-white/20 rounded-full flex items-center justify-center backdrop-blur-sm"
>
<X className="w-5 h-5 text-white" />
</button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+2 -1
View File
@@ -1,4 +1,4 @@
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight } from "lucide-react";
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, Wand2 } from "lucide-react";
import { Link, useLocation } from "wouter";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
@@ -63,6 +63,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
{ icon: PlusCircle, label: "Nouvelle publication", href: "/new", badge: null },
{ icon: Calendar, label: "Calendrier", href: "/calendar", badge: null },
{ icon: Images, label: "Médiathèque", href: "/media", badge: null },
{ icon: Wand2, label: "Éditeur d'images", href: "/image-editor", badge: null },
];
const statsItems = [
+749
View File
@@ -0,0 +1,749 @@
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { toPng } from 'html-to-image';
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
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, ArrowRight, Image as ImageIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
interface Ribbon {
enabled: boolean;
text: "Promo" | "Nouveauté" | "Arrivage";
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";
}
interface Filters {
brightness: number;
contrast: number;
saturation: number;
blur: number;
sharpen: number;
effect: "none" | "grayscale" | "sepia" | "vintage";
}
export default function ImageEditor() {
const [sidebarOpen, setSidebarOpen] = useState(false);
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"
});
const [filters, setFilters] = useState<Filters>({
brightness: 0,
contrast: 0,
saturation: 0,
blur: 0,
sharpen: 0,
effect: "none"
});
const [previewUrl, setPreviewUrl] = useState<string>("");
const { data: mediaList } = useQuery({
queryKey: ["/api/media"],
});
const previewRef = useRef<HTMLDivElement>(null);
const { toast } = useToast();
// State for generated ribbon overlay
const [ribbonPublicId, setRibbonPublicId] = useState<string | null>(null);
const [isGeneratingRibbon, setIsGeneratingRibbon] = useState(false);
// Save edited image mutation - DOM capture with html-to-image
const saveImageMutation = useMutation({
mutationFn: async () => {
if (!selectedMedia || !previewRef.current) {
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
});
// 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();
},
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"
});
}
});
// Generate ribbon when settings change
useEffect(() => {
if (!ribbon.enabled || !ribbon.text) {
setRibbonPublicId(null);
return;
}
const generateRibbon = async () => {
setIsGeneratingRibbon(true);
try {
const response = await apiRequest("POST", "/api/media/generate-ribbon", {
text: ribbon.text,
color: ribbon.color,
position: ribbon.position
});
const data = await response.json();
setRibbonPublicId(data.publicId);
} catch (error) {
console.error("Error generating ribbon:", error);
} finally {
setIsGeneratingRibbon(false);
}
};
// Debounce to avoid too many requests
const timer = setTimeout(generateRibbon, 500);
return () => clearTimeout(timer);
}, [ribbon.enabled, ribbon.text, ribbon.color, ribbon.position]);
// Generate Cloudinary transformation URL
useEffect(() => {
if (!selectedMedia) {
setPreviewUrl("");
return;
}
// Start with base image URL
let url = selectedMedia.originalUrl;
// Extract Cloudinary parts
const cloudinaryPattern = /https:\/\/res\.cloudinary\.com\/([^\/]+)\/image\/upload\//;
const match = url.match(cloudinaryPattern);
if (!match) {
setPreviewUrl(url);
return;
}
const [fullMatch, cloudName] = match;
const imagePath = url.replace(fullMatch, '');
// Build transformations array
const transformations: string[] = [];
// 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.effect !== "none") {
transformations.push(`e_${filters.effect}`);
}
// 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`
);
}
// Construct final URL
const transformedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/${transformations.join('/')}/${imagePath}`;
setPreviewUrl(transformedUrl);
}, [selectedMedia, ribbon, priceBadge, logo, filters]);
return (
<div className="flex h-screen overflow-hidden bg-background">
<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;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
}
.ribbon-container.north_west .ribbon-text {
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg) translateY(-15px);
}
.ribbon-container.north_east .ribbon-text {
top: 50%;
right: 50%;
transform: translate(50%, -50%) rotate(45deg) translateY(-15px);
}
`}</style>
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<Wand2 className="w-8 h-8" />
Éditeur d'images
</h1>
<p className="text-muted-foreground mt-2">
Ajoutez des rubans promotionnels, badges prix et filtres à vos images
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column - Controls */}
<div className="space-y-6">
{/* Image Selection */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ImageIcon className="w-5 h-5" />
Sélectionner 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' : 'border-border 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>
) : (
<p className="text-sm text-muted-foreground text-center py-8">
Aucune image disponible. Uploadez des images dans la médiathèque.
</p>
)}
</CardContent>
</Card>
{/* Ribbon Controls */}
<Card>
<CardHeader>
<CardTitle>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>
</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>Badge 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>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle>Filtres</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"
/>
</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>
<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>Effet</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>
</SelectContent>
</Select>
</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="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.length <= 5 ? '22px' :
ribbon.text.length <= 8 ? '18px' :
ribbon.text.length <= 11 ? '15px' : '12px'
}}
>
{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>
)}
</div>
<button
onClick={() => setSelectedMedia(null)}
className="absolute top-2 right-2 p-1.5 bg-black/50 hover:bg-black/70 rounded-full text-white transition-colors z-20"
data-testid="button-clear-selection"
title="Désélectionner l'image"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex gap-3">
<Button
className="flex-1"
variant="outline"
data-testid="button-save"
onClick={() => saveImageMutation.mutate()}
disabled={saveImageMutation.isPending}
>
<Save className="w-4 h-4 mr-2" />
{saveImageMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
<Button className="flex-1" data-testid="button-use-in-post">
<ArrowRight className="w-4 h-4 mr-2" />
Utiliser dans un post
</Button>
</div>
</div>
) : (
<div className="aspect-square flex items-center justify-center bg-muted rounded-lg">
<p className="text-muted-foreground">Sélectionnez une image</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</main>
</div>
);
}
+427
View File
@@ -48,6 +48,7 @@
"@types/node-cron": "^3.0.11",
"@types/pg": "^8.15.5",
"bcrypt": "^6.0.0",
"canvas": "^3.2.0",
"class-variance-authority": "^0.7.1",
"cloudinary": "^2.7.0",
"clsx": "^2.1.1",
@@ -60,6 +61,8 @@
"express": "^4.21.2",
"express-session": "^1.18.1",
"framer-motion": "^11.13.1",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.453.0",
"memorystore": "^1.6.7",
@@ -4375,6 +4378,35 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
@@ -4401,6 +4433,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -4494,6 +4537,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -4583,6 +4650,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvas": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.0.tgz",
"integrity": "sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.0.0",
"prebuild-install": "^7.1.3"
},
"engines": {
"node": "^18.12.0 || >= 20.9.0"
}
},
"node_modules/canvas/node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -4619,6 +4706,12 @@
"node": ">= 6"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -4777,6 +4870,15 @@
"node": ">= 8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -4948,6 +5050,30 @@
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -5229,6 +5355,15 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
@@ -5349,6 +5484,15 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -5636,6 +5780,12 @@
"node": ">= 0.6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -5709,6 +5859,12 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@@ -5807,6 +5963,25 @@
"node": ">= 0.4"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -5835,12 +6010,38 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/input-otp": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
@@ -6418,6 +6619,18 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -6469,6 +6682,12 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/modern-screenshot": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.6.0.tgz",
@@ -6537,6 +6756,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -6555,6 +6780,30 @@
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/node-abi": {
"version": "3.78.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz",
"integrity": "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-abi/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
@@ -6667,6 +6916,15 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -7089,6 +7347,32 @@
"integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==",
"license": "MIT"
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -7123,6 +7407,16 @@
"integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==",
"license": "ISC"
},
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/q": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
@@ -7202,6 +7496,21 @@
"node": ">= 0.8"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -7815,6 +8124,51 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -7976,6 +8330,15 @@
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@@ -8072,6 +8435,43 @@
"node": ">=6"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -8152,6 +8552,18 @@
"fsevents": "~2.3.3"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/tw-animate-css": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.5.tgz",
@@ -8316,6 +8728,15 @@
"node": ">= 0.4.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -8969,6 +9390,12 @@
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
+3
View File
@@ -50,6 +50,7 @@
"@types/node-cron": "^3.0.11",
"@types/pg": "^8.15.5",
"bcrypt": "^6.0.0",
"canvas": "^3.2.0",
"class-variance-authority": "^0.7.1",
"cloudinary": "^2.7.0",
"clsx": "^2.1.1",
@@ -62,6 +63,8 @@
"express": "^4.21.2",
"express-session": "^1.18.1",
"framer-motion": "^11.13.1",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.453.0",
"memorystore": "^1.6.7",
+210
View File
@@ -517,6 +517,196 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Apply overlays to image using Sharp (server-side processing)
// IMPORTANT: This must be BEFORE /api/media/:id to avoid being intercepted by :id parameter
app.post("/api/media/apply-overlays", requireAuth, async (req, res) => {
try {
const { imageUrl, ribbon, priceBadge } = req.body;
const user = req.user as User;
const userId = user.id;
if (!imageUrl) {
return res.status(400).json({ error: "Image URL required" });
}
console.log("🎨 Applying overlays server-side with Sharp...");
console.log("📥 Image URL:", imageUrl);
console.log("🎀 Ribbon:", ribbon);
console.log("💰 Price Badge:", priceBadge);
// Download image from URL
const imageResponse = await fetch(imageUrl);
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
console.log("✅ Image downloaded:", imageBuffer.length, "bytes");
// Load with Sharp to process
const sharp = (await import("sharp")).default;
let image = sharp(imageBuffer);
// Get image metadata for dimensions
const metadata = await image.metadata();
const width = metadata.width!;
const height = metadata.height!;
console.log(`📐 Image dimensions: ${width}x${height}`);
// Create SVG overlays
const overlays: any[] = [];
// Add ribbon overlay
if (ribbon) {
const ribbonSize = 150;
const color = ribbon.color === 'red' ? '#dc2626' : '#eab308';
const fontSize = ribbon.text.length <= 5 ? 22 : ribbon.text.length <= 8 ? 18 : ribbon.text.length <= 11 ? 15 : 12;
// Position mapping for each corner
const positions: Record<string, { x: number; y: number; polygon: string; textX: number; textY: number; textRotation: number }> = {
north_west: {
x: 0,
y: 0,
polygon: `0,0 ${ribbonSize},0 0,${ribbonSize}`, // Top-left triangle
textX: ribbonSize * 0.3,
textY: ribbonSize * 0.3,
textRotation: -45
},
north_east: {
x: width - ribbonSize,
y: 0,
polygon: `0,0 ${ribbonSize},0 ${ribbonSize},${ribbonSize}`, // Top-right triangle
textX: ribbonSize * 0.7,
textY: ribbonSize * 0.3,
textRotation: 45
},
south_west: {
x: 0,
y: height - ribbonSize,
polygon: `0,0 0,${ribbonSize} ${ribbonSize},${ribbonSize}`, // Bottom-left triangle
textX: ribbonSize * 0.3,
textY: ribbonSize * 0.7,
textRotation: -135
},
south_east: {
x: width - ribbonSize,
y: height - ribbonSize,
polygon: `${ribbonSize},0 0,${ribbonSize} ${ribbonSize},${ribbonSize}`, // Bottom-right triangle
textX: ribbonSize * 0.7,
textY: ribbonSize * 0.7,
textRotation: 135
}
};
const pos = positions[ribbon.position] || positions.north_west;
const ribbonSvg = `
<svg width="${ribbonSize}" height="${ribbonSize}" xmlns="http://www.w3.org/2000/svg">
<polygon points="${pos.polygon}" fill="${color}"/>
<text x="${pos.textX}" y="${pos.textY}"
font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold"
fill="white" text-anchor="middle" dominant-baseline="middle"
transform="rotate(${pos.textRotation}, ${pos.textX}, ${pos.textY})">
${ribbon.text}
</text>
</svg>
`;
overlays.push({
input: Buffer.from(ribbonSvg),
top: pos.y,
left: pos.x
});
}
// Add price badge overlay
if (priceBadge) {
const padding = 16;
const badgeText = `€${priceBadge.price}`;
const fontSize = priceBadge.size;
const textWidth = badgeText.length * fontSize * 0.6; // Approximate
const badgeWidth = textWidth + padding * 2;
const badgeHeight = fontSize + padding;
const color = priceBadge.color === 'red' ? '#dc2626' : '#eab308';
let x = padding;
let y = padding;
if (priceBadge.position === 'north_east') {
x = width - badgeWidth - padding;
} else if (priceBadge.position === 'south_west') {
y = height - badgeHeight - padding;
} else if (priceBadge.position === 'south_east') {
x = width - badgeWidth - padding;
y = height - badgeHeight - padding;
}
const badgeSvg = `
<svg width="${badgeWidth}" height="${badgeHeight}">
<rect x="0" y="0" width="${badgeWidth}" height="${badgeHeight}"
rx="${badgeHeight/2}" ry="${badgeHeight/2}" fill="${color}"/>
<text x="${badgeWidth/2}" y="${badgeHeight/2}"
font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold"
fill="white" text-anchor="middle" dominant-baseline="middle">
${badgeText}
</text>
</svg>
`;
overlays.push({
input: Buffer.from(badgeSvg),
top: y,
left: x
});
}
// Apply all overlays
if (overlays.length > 0) {
image = image.composite(overlays);
}
// Convert to buffer
const outputBuffer = await image.jpeg({ quality: 95 }).toBuffer();
console.log("✅ Overlays applied, uploading to Cloudinary...");
// Check if Cloudinary is configured
const cloudinaryConfig = await storage.getCloudinaryConfig(userId);
if (!cloudinaryConfig) {
return res.status(400).json({
error: "Cloudinary not configured. Please configure Cloudinary in Settings first."
});
}
// Upload to Cloudinary
const uploadResult = await cloudinaryService.uploadMedia(
outputBuffer,
`edited_${Date.now()}.jpg`,
userId,
'image/jpeg'
);
// Save to database
// IMPORTANT: For edited images with overlays, use originalUrl for ALL formats
// because Cloudinary transformations would recreate versions WITHOUT the overlays
const mediaItem = await storage.createMedia({
userId,
type: "image",
cloudinaryPublicId: uploadResult.publicId,
originalUrl: uploadResult.originalUrl,
facebookFeedUrl: uploadResult.originalUrl, // Use original with overlays
instagramFeedUrl: uploadResult.originalUrl, // Use original with overlays
instagramStoryUrl: uploadResult.originalUrl, // Use original with overlays
fileName: `edited_${Date.now()}.jpg`,
fileSize: outputBuffer.length,
});
console.log("✅ Image saved with ID:", mediaItem.id);
res.json(mediaItem);
} catch (error) {
console.error("💥 Error applying overlays:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to apply overlays";
res.status(500).json({ error: errorMessage });
}
});
// Delete media
app.delete("/api/media/:id", requireAuth, async (req, res) => {
try {
@@ -1151,6 +1341,26 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Generate ribbon overlay
app.post("/api/media/generate-ribbon", requireAuth, async (req, res) => {
const { text, color, position } = req.body;
if (!text || !color || !position) {
return res.status(400).json({ error: "Text, color and position are required" });
}
try {
const { RibbonGenerator } = await import("./services/ribbonGenerator");
const generator = RibbonGenerator.getInstance();
const publicId = await generator.generateTriangularRibbon(text, color, position);
res.json({ publicId });
} catch (error) {
console.error("Error generating ribbon:", error);
res.status(500).json({ error: "Failed to generate ribbon overlay" });
}
});
const httpServer = createServer(app);
return httpServer;
+87
View File
@@ -0,0 +1,87 @@
import { createCanvas } from "canvas";
import cloudinary from "cloudinary";
export class RibbonGenerator {
private static instance: RibbonGenerator;
static getInstance(): RibbonGenerator {
if (!RibbonGenerator.instance) {
RibbonGenerator.instance = new RibbonGenerator();
}
return RibbonGenerator.instance;
}
// Generate a triangular corner ribbon
async generateTriangularRibbon(
text: string,
color: "red" | "yellow",
position: "north_west" | "north_east"
): Promise<string> {
const width = 200;
const height = 200;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
// Clear canvas with transparency
ctx.clearRect(0, 0, width, height);
// Set ribbon color
const ribbonColor = color === "red" ? "#FF0000" : "#FFC107";
ctx.fillStyle = ribbonColor;
// Draw triangle based on position
ctx.beginPath();
if (position === "north_west") {
// Triangle in top-left corner
ctx.moveTo(0, 0);
ctx.lineTo(150, 0);
ctx.lineTo(0, 150);
} else {
// Triangle in top-right corner
ctx.moveTo(width, 0);
ctx.lineTo(width - 150, 0);
ctx.lineTo(width, 150);
}
ctx.closePath();
ctx.fill();
// Add text on the diagonal
ctx.save();
ctx.fillStyle = "white";
ctx.font = "bold 24px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (position === "north_west") {
// Rotate for northwest corner
ctx.translate(50, 50);
ctx.rotate(-Math.PI / 4);
} else {
// Rotate for northeast corner
ctx.translate(width - 50, 50);
ctx.rotate(Math.PI / 4);
}
ctx.fillText(text, 0, 0);
ctx.restore();
// Convert to base64
const buffer = canvas.toBuffer("image/png");
const base64 = `data:image/png;base64,${buffer.toString("base64")}`;
// Upload to Cloudinary
try {
const result = await cloudinary.v2.uploader.upload(base64, {
folder: "ribbon-overlays",
public_id: `ribbon_${text}_${color}_${position}_${Date.now()}`,
resource_type: "image"
});
return result.public_id;
} catch (error) {
console.error("Error uploading ribbon to Cloudinary:", error);
throw error;
}
}
}