diff --git a/.replit b/.replit index 164cd5a..7c5c48e 100644 --- a/.replit +++ b/.replit @@ -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 diff --git a/attached_assets/image_1760171183819.png b/attached_assets/image_1760171183819.png new file mode 100644 index 0000000..f16d17a Binary files /dev/null and b/attached_assets/image_1760171183819.png differ diff --git a/attached_assets/image_1760171388112.png b/attached_assets/image_1760171388112.png new file mode 100644 index 0000000..fb76ee1 Binary files /dev/null and b/attached_assets/image_1760171388112.png differ diff --git a/attached_assets/image_1760171481083.png b/attached_assets/image_1760171481083.png new file mode 100644 index 0000000..e4ad39a Binary files /dev/null and b/attached_assets/image_1760171481083.png differ diff --git a/attached_assets/image_1760171788554.png b/attached_assets/image_1760171788554.png new file mode 100644 index 0000000..3de01ad Binary files /dev/null and b/attached_assets/image_1760171788554.png differ diff --git a/attached_assets/image_1760171960442.png b/attached_assets/image_1760171960442.png new file mode 100644 index 0000000..bb54a27 Binary files /dev/null and b/attached_assets/image_1760171960442.png differ diff --git a/attached_assets/image_1760172035981.png b/attached_assets/image_1760172035981.png new file mode 100644 index 0000000..02be962 Binary files /dev/null and b/attached_assets/image_1760172035981.png differ diff --git a/attached_assets/image_1760172198103.png b/attached_assets/image_1760172198103.png new file mode 100644 index 0000000..8d7e462 Binary files /dev/null and b/attached_assets/image_1760172198103.png differ diff --git a/attached_assets/image_1760172363482.png b/attached_assets/image_1760172363482.png new file mode 100644 index 0000000..f707f6b Binary files /dev/null and b/attached_assets/image_1760172363482.png differ diff --git a/attached_assets/image_1760172977863.png b/attached_assets/image_1760172977863.png new file mode 100644 index 0000000..0e837aa Binary files /dev/null and b/attached_assets/image_1760172977863.png differ diff --git a/attached_assets/image_1760173076745.png b/attached_assets/image_1760173076745.png new file mode 100644 index 0000000..cc13459 Binary files /dev/null and b/attached_assets/image_1760173076745.png differ diff --git a/attached_assets/image_1760173282412.png b/attached_assets/image_1760173282412.png new file mode 100644 index 0000000..122ff7c Binary files /dev/null and b/attached_assets/image_1760173282412.png differ diff --git a/attached_assets/image_1760173351446.png b/attached_assets/image_1760173351446.png new file mode 100644 index 0000000..0773449 Binary files /dev/null and b/attached_assets/image_1760173351446.png differ diff --git a/attached_assets/image_1760173933493.png b/attached_assets/image_1760173933493.png new file mode 100644 index 0000000..1de1bdb Binary files /dev/null and b/attached_assets/image_1760173933493.png differ diff --git a/attached_assets/image_1760173981189.png b/attached_assets/image_1760173981189.png new file mode 100644 index 0000000..1de1bdb Binary files /dev/null and b/attached_assets/image_1760173981189.png differ diff --git a/attached_assets/image_1760174077034.png b/attached_assets/image_1760174077034.png new file mode 100644 index 0000000..af1dc9d Binary files /dev/null and b/attached_assets/image_1760174077034.png differ diff --git a/attached_assets/image_1760174247795.png b/attached_assets/image_1760174247795.png new file mode 100644 index 0000000..a6f6779 Binary files /dev/null and b/attached_assets/image_1760174247795.png differ diff --git a/attached_assets/image_1760174660512.png b/attached_assets/image_1760174660512.png new file mode 100644 index 0000000..e65772e Binary files /dev/null and b/attached_assets/image_1760174660512.png differ diff --git a/attached_assets/image_1760174717828.png b/attached_assets/image_1760174717828.png new file mode 100644 index 0000000..8a9811c Binary files /dev/null and b/attached_assets/image_1760174717828.png differ diff --git a/attached_assets/image_1760174725366.png b/attached_assets/image_1760174725366.png new file mode 100644 index 0000000..4b9ad27 Binary files /dev/null and b/attached_assets/image_1760174725366.png differ diff --git a/attached_assets/image_1760174917782.png b/attached_assets/image_1760174917782.png new file mode 100644 index 0000000..acd6432 Binary files /dev/null and b/attached_assets/image_1760174917782.png differ diff --git a/attached_assets/image_1760174925779.png b/attached_assets/image_1760174925779.png new file mode 100644 index 0000000..a76d63c Binary files /dev/null and b/attached_assets/image_1760174925779.png differ diff --git a/attached_assets/image_1760175090939.png b/attached_assets/image_1760175090939.png new file mode 100644 index 0000000..60db724 Binary files /dev/null and b/attached_assets/image_1760175090939.png differ diff --git a/attached_assets/image_1760175257880.png b/attached_assets/image_1760175257880.png new file mode 100644 index 0000000..7a215f0 Binary files /dev/null and b/attached_assets/image_1760175257880.png differ diff --git a/attached_assets/image_1760175704183.png b/attached_assets/image_1760175704183.png new file mode 100644 index 0000000..73dac4a Binary files /dev/null and b/attached_assets/image_1760175704183.png differ diff --git a/attached_assets/image_1760175713144.png b/attached_assets/image_1760175713144.png new file mode 100644 index 0000000..307d124 Binary files /dev/null and b/attached_assets/image_1760175713144.png differ diff --git a/attached_assets/image_1760175899848.png b/attached_assets/image_1760175899848.png new file mode 100644 index 0000000..b813a52 Binary files /dev/null and b/attached_assets/image_1760175899848.png differ diff --git a/attached_assets/image_1760176035030.png b/attached_assets/image_1760176035030.png new file mode 100644 index 0000000..46606ae Binary files /dev/null and b/attached_assets/image_1760176035030.png differ diff --git a/attached_assets/image_1760176326976.png b/attached_assets/image_1760176326976.png new file mode 100644 index 0000000..2770b7c Binary files /dev/null and b/attached_assets/image_1760176326976.png differ diff --git a/attached_assets/image_1760176477882.png b/attached_assets/image_1760176477882.png new file mode 100644 index 0000000..3d3f038 Binary files /dev/null and b/attached_assets/image_1760176477882.png differ diff --git a/attached_assets/image_1760179705138.png b/attached_assets/image_1760179705138.png new file mode 100644 index 0000000..104a23b Binary files /dev/null and b/attached_assets/image_1760179705138.png differ diff --git a/attached_assets/image_1760179764774.png b/attached_assets/image_1760179764774.png new file mode 100644 index 0000000..344fca0 Binary files /dev/null and b/attached_assets/image_1760179764774.png differ diff --git a/client/src/App.tsx b/client/src/App.tsx index 7fd12f2..52d1fc6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { {() => } {() => } {() => } + {() => } {() => } {() => } {() => } diff --git a/client/src/components/media-upload.tsx b/client/src/components/media-upload.tsx index 8a8d3c7..78e818e 100644 --- a/client/src/components/media-upload.tsx +++ b/client/src/components/media-upload.tsx @@ -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(null); const [visibleCount, setVisibleCount] = useState(5); + const [zoomImage, setZoomImage] = useState(null); const loadMoreRef = useRef(null); const { data: mediaList } = useQuery({ @@ -167,18 +168,32 @@ export default function MediaUpload() { className="w-full h-full object-cover" /> )} - +
+ {media.type === "image" && ( + + )} + +
- ))} + ) as any)} {/* Élément sentinelle pour le scroll infini */} @@ -264,6 +279,25 @@ export default function MediaUpload() { + + {/* Modal pour voir l'image originale en plein écran */} + setZoomImage(null)}> + +
+ Image originale + +
+
+
); } diff --git a/client/src/components/sidebar.tsx b/client/src/components/sidebar.tsx index 3e6e0a2..e8870e5 100644 --- a/client/src/components/sidebar.tsx +++ b/client/src/components/sidebar.tsx @@ -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 = [ diff --git a/client/src/pages/image-editor.tsx b/client/src/pages/image-editor.tsx new file mode 100644 index 0000000..9862398 --- /dev/null +++ b/client/src/pages/image-editor.tsx @@ -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(null); + + const [ribbon, setRibbon] = useState({ + enabled: false, + text: "Promo", + color: "red", + position: "north_west" + }); + + const [priceBadge, setPriceBadge] = useState({ + enabled: false, + price: "", + color: "red", + position: "north_east", + size: 20 // Default font size + }); + + const [logo, setLogo] = useState({ + enabled: false, + opacity: 60, + position: "south_east" + }); + + const [filters, setFilters] = useState({ + brightness: 0, + contrast: 0, + saturation: 0, + blur: 0, + sharpen: 0, + effect: "none" + }); + + const [previewUrl, setPreviewUrl] = useState(""); + + const { data: mediaList } = useQuery({ + queryKey: ["/api/media"], + }); + + const previewRef = useRef(null); + const { toast } = useToast(); + + // State for generated ribbon overlay + const [ribbonPublicId, setRibbonPublicId] = useState(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((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 ( +
+ + + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + +
+ setSidebarOpen(false)} /> +
+ +
+ setSidebarOpen(!sidebarOpen)} /> + +
+
+

+ + Éditeur d'images +

+

+ Ajoutez des rubans promotionnels, badges prix et filtres à vos images +

+
+ +
+ {/* Left Column - Controls */} +
+ {/* Image Selection */} + + + + + Sélectionner une image + + + + {mediaList && (mediaList as any[]).filter((m: any) => m.type === 'image').length > 0 ? ( +
+ {(mediaList as any[]) + .filter((m: any) => m.type === 'image') + .map((media: any) => ( +
setSelectedMedia(media)} + data-testid={`select-image-${media.id}`} + > + +
+ ))} +
+ ) : ( +

+ Aucune image disponible. Uploadez des images dans la médiathèque. +

+ )} +
+
+ + {/* Ribbon Controls */} + + + Ruban promotionnel + + +
+ setRibbon({ ...ribbon, enabled: checked as boolean })} + data-testid="checkbox-ribbon" + /> + +
+ + {ribbon.enabled && ( + <> +
+ + setRibbon({ ...ribbon, text: value as any })} + > +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + setRibbon({ ...ribbon, color: value as any })} + > +
+ + +
+
+ + +
+
+
+ +
+ + +
+ + )} +
+
+ + {/* Price Badge */} + + + Badge prix + + +
+ setPriceBadge({ ...priceBadge, enabled: checked as boolean })} + data-testid="checkbox-price" + /> + +
+ + {priceBadge.enabled && ( + <> +
+ + setPriceBadge({ ...priceBadge, price: e.target.value })} + data-testid="input-price" + /> +
+ +
+ + setPriceBadge({ ...priceBadge, color: value as any })} + > +
+ + +
+
+ + +
+
+
+ +
+ + +
+ +
+
+ + {priceBadge.size}px +
+ setPriceBadge({ ...priceBadge, size: value[0] })} + min={12} + max={70} + step={1} + data-testid="slider-price-size" + /> +
+ + )} +
+
+ + {/* Filters */} + + + Filtres + + +
+
+ + {filters.brightness} +
+ setFilters({ ...filters, brightness: value })} + min={-50} + max={50} + step={5} + data-testid="slider-brightness" + /> +
+ +
+
+ + {filters.contrast} +
+ setFilters({ ...filters, contrast: value })} + min={-50} + max={50} + step={5} + data-testid="slider-contrast" + /> +
+ +
+
+ + {filters.saturation} +
+ setFilters({ ...filters, saturation: value })} + min={-100} + max={100} + step={10} + data-testid="slider-saturation" + /> +
+ +
+ + +
+
+
+
+ + {/* Right Column - Preview */} +
+ + + Aperçu + + + {selectedMedia ? ( +
+
+
+ Aperçu + + {/* CSS Triangle Ribbon Overlay - Auto-centered */} + {ribbon.enabled && ribbon.text && ( +
+
+
+ {ribbon.text} +
+
+ )} + + {/* Price Badge Overlay (CSS) */} + {priceBadge.enabled && priceBadge.price && ( +
+ €{priceBadge.price} +
+ )} +
+ + +
+ +
+ + +
+
+ ) : ( +
+

Sélectionnez une image

+
+ )} + + +
+
+
+
+
+ ); +} diff --git a/package-lock.json b/package-lock.json index b32a42f..45fd703 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 81dab0c..e688d05 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/routes.ts b/server/routes.ts index 9a666f2..2edb427 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -517,6 +517,196 @@ export async function registerRoutes(app: Express): Promise { } }); + // 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 = { + 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 = ` + + + + ${ribbon.text} + + + `; + + 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 = ` + + + + ${badgeText} + + + `; + + 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 { } }); + // 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; diff --git a/server/services/ribbonGenerator.ts b/server/services/ribbonGenerator.ts new file mode 100644 index 0000000..ffac041 --- /dev/null +++ b/server/services/ribbonGenerator.ts @@ -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 { + 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; + } + } +} \ No newline at end of file