Add ability to upload and apply custom logos to social media posts

Implement functionality for uploading, applying, and deleting custom logos as overlays on images, along with backend processing and UI updates.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Event-Id: 91999614-5187-4902-b7a1-bf9e372de9c8
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/JgqpaNl
This commit is contained in:
michaelschal committed 2025-11-18 08:47:42 +00:00
1 parent 6ee2b61dd9
commit 225e4f2292
6 files changed
+493 -252

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

+94 -250
View File
@@ -35,19 +35,7 @@ interface Logo {
enabled: boolean;
opacity: number;
position: "north_east" | "south_east" | "north_west" | "south_west" | "center";
}
interface Filters {
brightness: number;
contrast: number;
saturation: number;
blur: number;
sharpen: number;
vibrance: number;
hue: number;
gamma: number;
vignette: number;
effect: "none" | "grayscale" | "sepia" | "vintage" | "incognito" | "athena" | "audrey";
size: "small" | "medium" | "large";
}
export default function ImageEditor() {
@@ -72,20 +60,8 @@ export default function ImageEditor() {
const [logo, setLogo] = useState<Logo>({
enabled: false,
opacity: 60,
position: "south_east"
});
const [filters, setFilters] = useState<Filters>({
brightness: 0,
contrast: 0,
saturation: 0,
blur: 0,
sharpen: 0,
vibrance: 0,
hue: 0,
gamma: 0,
vignette: 0,
effect: "none"
position: "south_east",
size: "medium"
});
const [previewUrl, setPreviewUrl] = useState<string>("");
@@ -94,58 +70,30 @@ export default function ImageEditor() {
queryKey: ["/api/media"],
});
const { data: cloudinaryConfig } = useQuery({
queryKey: ['/api/cloudinary/config'],
});
const previewRef = useRef<HTMLDivElement>(null);
const { toast } = useToast();
// Save edited image mutation - DOM capture with html-to-image
// Save edited image mutation - Server-side processing with Sharp
const saveImageMutation = useMutation({
mutationFn: async () => {
if (!selectedMedia || !previewRef.current) {
if (!selectedMedia) {
throw new Error("Aucune image sélectionnée");
}
// Capture preview exactly as displayed (no scaling)
const dataUrl = await toPng(previewRef.current, {
quality: 0.95,
pixelRatio: 1, // Keep 1:1 - no zoom
});
// Send image URL and overlay parameters to backend
const payload = {
imageUrl: selectedMedia.originalUrl,
ribbon: ribbon.enabled ? ribbon : undefined,
priceBadge: priceBadge.enabled && priceBadge.price ? priceBadge : undefined,
logo: logo.enabled ? logo : undefined,
};
// Convert data URL to blob
const response = await fetch(dataUrl);
let blob = await response.blob();
// Compress if needed to stay under Cloudinary limit
if (blob.size > 8 * 1024 * 1024) {
const img = new Image();
img.src = dataUrl;
await new Promise((resolve) => { img.onload = resolve; });
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0);
blob = await new Promise<Blob>((resolve) => {
canvas.toBlob((b) => resolve(b!), 'image/jpeg', 0.8);
});
}
// Upload
const formData = new FormData();
formData.append('file', blob, `edited_${Date.now()}.jpg`);
const uploadResponse = await fetch('/api/media/upload', {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!uploadResponse.ok) {
throw new Error("Erreur lors de l'upload");
}
return uploadResponse.json();
const response = await apiRequest('POST', '/api/media/apply-overlays', payload);
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
@@ -192,80 +140,18 @@ export default function ImageEditor() {
// Base resize
transformations.push('w_800,h_800,c_fit');
// Filters
if (filters.brightness !== 0) {
transformations.push(`e_brightness:${filters.brightness}`);
}
if (filters.contrast !== 0) {
transformations.push(`e_contrast:${filters.contrast}`);
}
if (filters.saturation !== 0) {
transformations.push(`e_saturation:${filters.saturation}`);
}
if (filters.blur > 0) {
transformations.push(`e_blur:${filters.blur * 100}`);
}
if (filters.sharpen > 0) {
transformations.push(`e_sharpen:${filters.sharpen * 100}`);
}
if (filters.vibrance !== 0) {
transformations.push(`e_vibrance:${filters.vibrance}`);
}
if (filters.hue !== 0) {
transformations.push(`e_hue:${filters.hue}`);
}
if (filters.gamma !== 0) {
transformations.push(`e_gamma:${filters.gamma}`);
}
if (filters.vignette > 0) {
transformations.push(`e_vignette:${filters.vignette}`);
}
if (filters.effect !== "none") {
// Map effects to proper Cloudinary transformations
switch(filters.effect) {
case "grayscale":
transformations.push("e_grayscale");
break;
case "sepia":
transformations.push("e_sepia");
break;
case "vintage":
// Vintage effect: combine sepia + vignette + reduced saturation
transformations.push("e_sepia:50");
transformations.push("e_vignette:30");
transformations.push("e_saturation:-20");
break;
case "incognito":
transformations.push("e_art:incognito");
break;
case "athena":
transformations.push("e_art:athena");
break;
case "audrey":
transformations.push("e_art:audrey");
break;
}
}
// Note: Ribbon and price badge overlays are rendered as CSS elements in preview
// They are NOT added to Cloudinary transformation URL
// This allows for better visual control and positioning
// Logo overlay - using uploaded logo if available
if (logo.enabled) {
// TODO: Implement logo upload/selection system
// For now, use a watermark text
transformations.push(
`l_text:Arial_32:© LOGO,co_white,o_${logo.opacity}`,
`fl_layer_apply,g_${logo.position},x_30,y_30`
);
}
// Logo overlay will be added by backend when saving
// Preview just shows the base image with CSS overlays
// Construct final URL
const transformedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/${transformations.join('/')}/${imagePath}`;
setPreviewUrl(transformedUrl);
}, [selectedMedia, ribbon, priceBadge, logo, filters]);
}, [selectedMedia, ribbon, priceBadge, logo]);
return (
<div className="flex h-screen overflow-hidden bg-background">
@@ -596,134 +482,72 @@ export default function ImageEditor() {
</CardContent>
</Card>
{/* Filters */}
{/* Logo */}
<Card>
<CardHeader>
<CardTitle>Filtres</CardTitle>
<CardTitle>Logo</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<div className="flex justify-between">
<Label>Luminosité</Label>
<span className="text-sm text-muted-foreground">{filters.brightness}</span>
</div>
<Slider
value={[filters.brightness]}
onValueChange={([value]) => setFilters({ ...filters, brightness: value })}
min={-50}
max={50}
step={5}
data-testid="slider-brightness"
<CardContent className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="logo-enabled"
checked={logo.enabled}
onCheckedChange={(checked) => setLogo({ ...logo, enabled: checked as boolean })}
data-testid="checkbox-logo-enabled"
/>
<Label htmlFor="logo-enabled" className="cursor-pointer">
Afficher le logo
</Label>
</div>
{logo.enabled && (
<>
<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">
<div className="flex justify-between">
<Label>Vibrance</Label>
<span className="text-sm text-muted-foreground">{filters.vibrance}</span>
</div>
<Slider
value={[filters.vibrance]}
onValueChange={([value]) => setFilters({ ...filters, vibrance: value })}
min={-100}
max={100}
step={10}
data-testid="slider-vibrance"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<Label>Teinte</Label>
<span className="text-sm text-muted-foreground">{filters.hue}°</span>
</div>
<Slider
value={[filters.hue]}
onValueChange={([value]) => setFilters({ ...filters, hue: value })}
min={-180}
max={180}
step={10}
data-testid="slider-hue"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<Label>Gamma</Label>
<span className="text-sm text-muted-foreground">{filters.gamma}</span>
</div>
<Slider
value={[filters.gamma]}
onValueChange={([value]) => setFilters({ ...filters, gamma: value })}
min={-50}
max={150}
step={10}
data-testid="slider-gamma"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<Label>Vignette</Label>
<span className="text-sm text-muted-foreground">{filters.vignette}</span>
</div>
<Slider
value={[filters.vignette]}
onValueChange={([value]) => setFilters({ ...filters, vignette: value })}
min={0}
max={100}
step={10}
data-testid="slider-vignette"
/>
</div>
<div className="space-y-2">
<Label>Effet artistique</Label>
<Select value={filters.effect} onValueChange={(value) => setFilters({ ...filters, effect: value as any })}>
<SelectTrigger data-testid="select-effect">
<Label>Position</Label>
<Select value={logo.position} onValueChange={(value) => setLogo({ ...logo, position: value as any })}>
<SelectTrigger data-testid="select-logo-position">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Aucun</SelectItem>
<SelectItem value="grayscale">Noir et blanc</SelectItem>
<SelectItem value="sepia">Sépia</SelectItem>
<SelectItem value="vintage">Vintage</SelectItem>
<SelectItem value="incognito">Incognito</SelectItem>
<SelectItem value="athena">Athena</SelectItem>
<SelectItem value="audrey">Audrey</SelectItem>
<SelectItem value="north_west">Haut gauche</SelectItem>
<SelectItem value="north_east">Haut droit</SelectItem>
<SelectItem value="south_west">Bas gauche</SelectItem>
<SelectItem value="south_east">Bas droit</SelectItem>
<SelectItem value="center">Centre</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Taille</Label>
<Select value={logo.size} onValueChange={(value) => setLogo({ ...logo, size: value as any })}>
<SelectTrigger data-testid="select-logo-size">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="small">Petit</SelectItem>
<SelectItem value="medium">Moyen</SelectItem>
<SelectItem value="large">Grand</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label>Opacité</Label>
<span className="text-sm text-muted-foreground">{logo.opacity}%</span>
</div>
<Slider
value={[logo.opacity]}
onValueChange={(value) => setLogo({ ...logo, opacity: value[0] })}
min={0}
max={100}
step={5}
data-testid="slider-logo-opacity"
/>
</div>
</>
)}
</CardContent>
</Card>
</div>
@@ -794,6 +618,26 @@ export default function ImageEditor() {
{priceBadge.price}€
</div>
)}
{/* Logo Overlay (CSS) */}
{logo.enabled && cloudinaryConfig && (cloudinaryConfig as any).logoPublicId && (
<img
src={`https://res.cloudinary.com/${(cloudinaryConfig as any).cloudName}/image/upload/${(cloudinaryConfig as any).logoPublicId}`}
alt="Logo"
className={`absolute ${
logo.position === "north_east" ? "top-4 right-4" :
logo.position === "south_east" ? "bottom-4 right-4" :
logo.position === "south_west" ? "bottom-4 left-4" :
logo.position === "center" ? "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" :
"top-4 left-4"
} z-10 ${
logo.size === "small" ? "w-16" :
logo.size === "large" ? "w-32" :
"w-24"
}`}
style={{ opacity: logo.opacity / 100 }}
/>
)}
</div>
<button
+184 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain } from "lucide-react";
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X } from "lucide-react";
import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
@@ -23,6 +23,8 @@ export default function Settings() {
const [openrouterApiKey, setOpenrouterApiKey] = useState("");
const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-3.5-sonnet");
const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const { toast } = useToast();
const { data: cloudinaryConfig } = useQuery({
@@ -41,6 +43,13 @@ export default function Settings() {
if (cloudinaryConfig) {
setCloudName((cloudinaryConfig as any).cloudName || "");
setApiKey((cloudinaryConfig as any).apiKey || "");
// Set logo preview if exists
if ((cloudinaryConfig as any).logoPublicId) {
const cloudName = (cloudinaryConfig as any).cloudName;
const logoPublicId = (cloudinaryConfig as any).logoPublicId;
setLogoPreview(`https://res.cloudinary.com/${cloudName}/image/upload/${logoPublicId}`);
}
}
}, [cloudinaryConfig]);
@@ -119,6 +128,82 @@ export default function Settings() {
},
});
const uploadLogoMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append('logo', file);
const response = await fetch('/api/cloudinary/logo', {
method: 'POST',
credentials: 'include',
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to upload logo');
}
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
toast({
title: "Logo uploadé",
description: "Votre logo a été enregistré avec succès",
});
setLogoFile(null);
},
onError: (error: Error) => {
toast({
title: "Erreur",
description: error.message || "Impossible d'uploader le logo",
variant: "destructive",
});
},
});
const deleteLogoMutation = useMutation({
mutationFn: () => apiRequest('DELETE', '/api/cloudinary/logo', {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
setLogoPreview(null);
setLogoFile(null);
toast({
title: "Logo supprimé",
description: "Votre logo a été supprimé avec succès",
});
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de supprimer le logo",
variant: "destructive",
});
},
});
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (!file.type.startsWith('image/')) {
toast({
title: "Erreur",
description: "Veuillez sélectionner une image",
variant: "destructive",
});
return;
}
setLogoFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setLogoPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
@@ -331,6 +416,104 @@ export default function Settings() {
</CardContent>
</Card>
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
<Image className="w-5 h-5" />
Logo de l'entreprise
</CardTitle>
<CardDescription>
Uploadez votre logo pour l'ajouter aux images éditées
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{logoPreview ? (
<div className="space-y-4">
<div className="relative w-full max-w-xs mx-auto">
<img
src={logoPreview}
alt="Logo"
className="w-full h-auto max-h-48 object-contain rounded-lg border-2 border-border"
/>
<Button
size="icon"
variant="destructive"
className="absolute top-2 right-2 h-8 w-8"
onClick={() => deleteLogoMutation.mutate()}
disabled={deleteLogoMutation.isPending}
data-testid="button-delete-logo"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="text-center">
<Label htmlFor="logo-upload-replace" className="cursor-pointer">
<Button
variant="outline"
className="w-full"
disabled={uploadLogoMutation.isPending}
data-testid="button-replace-logo"
onClick={() => document.getElementById('logo-upload-replace')?.click()}
>
<Upload className="w-4 h-4 mr-2" />
{uploadLogoMutation.isPending ? "Upload en cours..." : "Remplacer le logo"}
</Button>
</Label>
<input
id="logo-upload-replace"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
handleLogoChange(e);
if (e.target.files?.[0]) {
uploadLogoMutation.mutate(e.target.files[0]);
}
}}
/>
</div>
</div>
) : (
<div className="space-y-4">
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center">
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-4">
Aucun logo uploadé. Choisissez une image PNG ou JPG.
</p>
<Label htmlFor="logo-upload" className="cursor-pointer">
<Button
variant="outline"
disabled={uploadLogoMutation.isPending || !cloudinaryConfig}
data-testid="button-upload-logo"
onClick={() => document.getElementById('logo-upload')?.click()}
>
<Upload className="w-4 h-4 mr-2" />
{uploadLogoMutation.isPending ? "Upload en cours..." : "Uploader un logo"}
</Button>
</Label>
<input
id="logo-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
handleLogoChange(e);
if (e.target.files?.[0]) {
uploadLogoMutation.mutate(e.target.files[0]);
}
}}
/>
</div>
{!cloudinaryConfig && (
<p className="text-xs text-orange-600 dark:text-orange-400 text-center">
Veuillez d'abord configurer Cloudinary ci-dessus
</p>
)}
</div>
)}
</CardContent>
</Card>
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
+159 -1
View File
@@ -521,7 +521,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// 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 { imageUrl, ribbon, priceBadge, logo } = req.body;
const user = req.user as User;
const userId = user.id;
@@ -533,6 +533,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
console.log("📥 Image URL:", imageUrl);
console.log("🎀 Ribbon:", ribbon);
console.log("💰 Price Badge:", priceBadge);
console.log("🏢 Logo:", logo);
// Download image from URL
const imageResponse = await fetch(imageUrl);
@@ -657,6 +658,86 @@ export async function registerRoutes(app: Express): Promise<Server> {
});
}
// Add logo overlay
if (logo && logo.enabled) {
console.log("🏢 Adding logo overlay...");
// Get Cloudinary config to get logo public ID
const cloudinaryConfig = await storage.getAnyCloudinaryConfig();
if (cloudinaryConfig && cloudinaryConfig.logoPublicId) {
try {
// Build logo URL from Cloudinary
const logoUrl = `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}`;
console.log("📥 Downloading logo from:", logoUrl);
// Download logo
const logoResponse = await fetch(logoUrl);
const logoBuffer = Buffer.from(await logoResponse.arrayBuffer());
// Determine logo size based on selection
const logoSizes = {
small: Math.round(width * 0.15), // 15% of image width
medium: Math.round(width * 0.25), // 25% of image width
large: Math.round(width * 0.35) // 35% of image width
};
const logoWidth = logoSizes[logo.size as keyof typeof logoSizes] || logoSizes.medium;
// Resize logo preserving aspect ratio and apply opacity
const resizedLogo = await sharp(logoBuffer)
.resize({ width: logoWidth, fit: 'contain' })
.ensureAlpha()
.toBuffer();
// Get resized logo dimensions
const logoMetadata = await sharp(resizedLogo).metadata();
const logoHeight = logoMetadata.height || logoWidth;
// Calculate position with padding
const padding = 20;
let logoX = padding;
let logoY = padding;
if (logo.position === 'north_east') {
logoX = width - logoWidth - padding;
} else if (logo.position === 'south_west') {
logoY = height - logoHeight - padding;
} else if (logo.position === 'south_east') {
logoX = width - logoWidth - padding;
logoY = height - logoHeight - padding;
} else if (logo.position === 'center') {
logoX = Math.round((width - logoWidth) / 2);
logoY = Math.round((height - logoHeight) / 2);
}
// Apply opacity by manipulating alpha channel
let logoWithOpacity = resizedLogo;
if (logo.opacity < 100) {
// Create a semi-transparent version of the logo
const opacityFactor = logo.opacity / 100;
logoWithOpacity = await sharp(resizedLogo)
.ensureAlpha()
.linear(opacityFactor, 0)
.toBuffer();
}
// Add logo overlay
overlays.push({
input: logoWithOpacity,
top: logoY,
left: logoX,
blend: 'over'
});
console.log(`✅ Logo added at position ${logo.position} with ${logo.opacity}% opacity`);
} catch (logoError) {
console.error("⚠️ Failed to apply logo:", logoError);
// Continue without logo if it fails
}
} else {
console.log("⚠️ No logo configured in settings");
}
}
// Apply all overlays
if (overlays.length > 0) {
image = image.composite(overlays);
@@ -1295,6 +1376,83 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Upload company logo
app.post("/api/cloudinary/logo", requireAdmin, upload.single("logo"), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: "No logo 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 first."
});
}
// Delete old logo if exists
if (cloudinaryConfig.logoPublicId) {
try {
await cloudinaryService.deleteLogo(cloudinaryConfig.logoPublicId);
} catch (error) {
console.warn("Failed to delete old logo:", error);
// Continue anyway - not critical
}
}
// Upload new logo to Cloudinary
const uploadResult = await cloudinaryService.uploadLogo(
req.file.buffer,
req.file.originalname
);
// Update config with new logo public ID
const updatedConfig = await storage.updateCloudinaryConfig(userId, {
logoPublicId: uploadResult.publicId,
});
res.json({
logoPublicId: uploadResult.publicId,
logoUrl: uploadResult.url,
});
} catch (error) {
console.error("Error uploading logo:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to upload logo";
res.status(500).json({ error: errorMessage });
}
});
// Delete company logo
app.delete("/api/cloudinary/logo", requireAdmin, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const cloudinaryConfig = await storage.getCloudinaryConfig(userId);
if (!cloudinaryConfig || !cloudinaryConfig.logoPublicId) {
return res.status(404).json({ error: "No logo found" });
}
// Delete from Cloudinary
await cloudinaryService.deleteLogo(cloudinaryConfig.logoPublicId);
// Remove from config
await storage.updateCloudinaryConfig(userId, {
logoPublicId: null,
});
res.json({ success: true });
} catch (error) {
console.error("Error deleting logo:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to delete logo";
res.status(500).json({ error: errorMessage });
}
});
// OpenRouter models list
app.get("/api/openrouter/models", requireAuth, async (req, res) => {
try {
+55
View File
@@ -132,6 +132,61 @@ class CloudinaryService {
return uploadResult.secure_url;
}
async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
const config = await storage.getAnyCloudinaryConfig();
if (!config) {
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
}
cloudinary.config({
cloud_name: config.cloudName,
api_key: config.apiKey,
api_secret: config.apiSecret,
secure: true,
});
const uploadResult = await new Promise<any>((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(
{
folder: 'social-flow/logos',
public_id: `logo-${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`,
resource_type: 'image',
},
(error, result) => {
if (error) reject(error);
else resolve(result);
}
);
uploadStream.end(buffer);
});
return {
publicId: uploadResult.public_id,
url: uploadResult.secure_url,
};
}
async deleteLogo(publicId: string): Promise<void> {
const config = await storage.getAnyCloudinaryConfig();
if (!config) {
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
}
cloudinary.config({
cloud_name: config.cloudName,
api_key: config.apiKey,
api_secret: config.apiSecret,
secure: true,
});
await cloudinary.uploader.destroy(publicId, {
resource_type: 'image',
});
}
}
export const cloudinaryService = new CloudinaryService();
+1
View File
@@ -36,6 +36,7 @@ export const cloudinaryConfig = pgTable("cloudinary_config", {
cloudName: text("cloud_name").notNull(),
apiKey: text("api_key").notNull(),
apiSecret: text("api_secret").notNull(),
logoPublicId: text("logo_public_id"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});