No files matched your search
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 650 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 687 KiB |
|
After Width: | Height: | Size: 715 KiB |
|
After Width: | Height: | Size: 719 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 703 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 619 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 219 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||