Add ability to save edited images with overlays to the media library

Implement a new endpoint for uploading edited images, converting them to a JPEG blob using html2canvas, and storing them in Cloudinary.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/VEZwUqa
This commit is contained in:
michaelschal committed 2025-10-11 09:10:37 +00:00
1 parent 986d122528
commit b764cdfc48
4 files changed
+167 -6

No files matched your search

+69 -6
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import html2canvas from "html2canvas";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -13,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
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;
@@ -84,10 +86,65 @@ export default function ImageEditor() {
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
const saveImageMutation = useMutation({
mutationFn: async () => {
if (!previewRef.current || !selectedMedia) {
throw new Error("Aucune image sélectionnée");
}
// Capture the preview container as canvas (including CSS overlays)
const canvas = await html2canvas(previewRef.current, {
backgroundColor: null,
scale: 2, // Higher quality
useCORS: true,
allowTaint: true
});
// Convert canvas to blob
const blob = await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => resolve(blob!), 'image/jpeg', 0.95);
});
// Upload to server
const formData = new FormData();
formData.append('file', blob, `edited_${Date.now()}.jpg`);
const response = await fetch('/api/media/upload-edited', {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!response.ok) {
throw new Error("Erreur lors de l'upload");
}
return response.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) {
@@ -591,7 +648,7 @@ export default function ImageEditor() {
{selectedMedia ? (
<div className="space-y-4">
<div className="rounded-lg overflow-hidden bg-muted aspect-square flex items-center justify-center">
<div className="relative w-full h-full">
<div ref={previewRef} className="relative w-full h-full">
<img
src={previewUrl || selectedMedia.originalUrl}
alt="Aperçu"
@@ -645,9 +702,15 @@ export default function ImageEditor() {
</div>
<div className="flex gap-3">
<Button className="flex-1" variant="outline" data-testid="button-save">
<Button
className="flex-1"
variant="outline"
data-testid="button-save"
onClick={() => saveImageMutation.mutate()}
disabled={saveImageMutation.isPending}
>
<Save className="w-4 h-4 mr-2" />
Enregistrer
{saveImageMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
<Button className="flex-1" data-testid="button-use-in-post">
<ArrowRight className="w-4 h-4 mr-2" />
+50
View File
@@ -61,6 +61,7 @@
"express": "^4.21.2",
"express-session": "^1.18.1",
"framer-motion": "^11.13.1",
"html2canvas": "^1.4.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.453.0",
"memorystore": "^1.6.7",
@@ -4376,6 +4377,15 @@
"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",
@@ -4859,6 +4869,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",
@@ -5943,6 +5962,19 @@
"node": ">= 0.4"
}
},
"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",
@@ -8424,6 +8456,15 @@
"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",
@@ -8680,6 +8721,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",
+1
View File
@@ -63,6 +63,7 @@
"express": "^4.21.2",
"express-session": "^1.18.1",
"framer-motion": "^11.13.1",
"html2canvas": "^1.4.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.453.0",
"memorystore": "^1.6.7",
+47
View File
@@ -1171,6 +1171,53 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Upload edited image with overlays
app.post("/api/media/upload-edited", requireAuth, upload.single("file"), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: "No file uploaded" });
}
const user = req.user as User;
const userId = user.id;
// 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 edited image to Cloudinary
const uploadResult = await cloudinaryService.uploadMedia(
req.file.buffer,
`edited_${Date.now()}.jpg`,
userId,
req.file.mimetype
);
const mediaItem = await storage.createMedia({
userId,
type: "image",
cloudinaryPublicId: uploadResult.publicId,
originalUrl: uploadResult.originalUrl,
facebookFeedUrl: uploadResult.facebookFeedUrl,
instagramFeedUrl: uploadResult.instagramFeedUrl,
instagramStoryUrl: uploadResult.instagramStoryUrl,
fileName: `edited_${Date.now()}.jpg`,
fileSize: req.file.size,
});
res.json(mediaItem);
} catch (error) {
console.error("Error uploading edited image:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to upload edited image";
res.status(500).json({ error: errorMessage });
}
});
const httpServer = createServer(app);
return httpServer;