Merge pull request #97 from R0m1k3/dev

Dev
This commit is contained in:
LogiFlow authored and GitHub committed 2025-11-18 10:02:14 +01:00
commit c972db429c
7 files changed
+508 -300

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

+99 -255
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>
<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>
{logo.enabled && (
<>
<div className="space-y-2">
<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="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">
<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>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">
<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">
<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>
</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">
+10 -43
View File
@@ -1,80 +1,47 @@
# Social Flow - AI-Powered Social Media Management Platform
## Overview
Social Flow is a comprehensive AI-powered social media automation platform designed to streamline content creation and publication across Facebook and Instagram. It enables users to manage multiple social media pages, generate AI-powered post content, optimize media for different platform formats, and schedule posts with automated publishing. The platform's ambition is to provide a complete, AI-driven solution for social media content management, significantly reducing manual effort and improving content quality and reach. Key capabilities include multi-photo carousel posts with drag-and-drop reordering, a preview modal for realistic platform rendering, and an integrated media library.
Social Flow is an AI-powered social media automation platform designed to streamline content creation and publication across Facebook and Instagram. Its core purpose is to enable users to manage multiple social media pages, generate AI-powered post content, optimize media for various platform formats, and schedule posts with automated publishing. The platform aims to be a complete, AI-driven solution for social media content management, reducing manual effort and improving content quality and reach. Key capabilities include multi-photo carousel posts with drag-and-drop reordering, a preview modal for realistic platform rendering, and an integrated media library.
## User Preferences
Preferred communication style: Simple, everyday language.
## Recent Changes
### November 18, 2025
- **Facebook Story Text Rendering Fix**: Fixed critical issue where text on Facebook Stories appeared as unreadable white squares (missing glyphs). Root cause: node-canvas didn't have access to proper fonts for rendering French characters and special characters. Solution: (1) Added TrueType font support packages to Dockerfile (`ttf-dejavu`, `fontconfig`, `curl`, `tar`, `bzip2`), (2) Dockerfile automatically downloads and installs DejaVu Sans font (740KB) during Docker build process from SourceForge with retry logic, ensuring the font is always available in production, (3) Registered font at module load using `registerFont()` from node-canvas with error handling, (4) Updated text rendering to explicitly use "DejaVu Sans" as primary font family with Arial/sans-serif fallbacks. This ensures all characters (French accents, emoji, special characters) display correctly on both Facebook and Instagram stories. The font is automatically installed in Docker builds and works in both development and production environments. Files modified: `Dockerfile`, `server/services/imageProcessor.ts`.
### November 17, 2025
- **Access Token Expiration Tracking**: Added automatic tracking and visual display of Facebook/Instagram access token expiration dates in the "Pages gérées" section. Implementation: (1) Added `tokenExpiresAt` timestamp column to `social_pages` table in database schema, (2) POST endpoint automatically calculates expiration date as current date + 90 days when adding a new page, (3) PUT endpoint recalculates expiration date (current date + 90 days) when updating the access token, (4) UI displays expiration status with color-coded visual indicators in page cards: green (>15 days), orange (7-15 days), red (<7 days or expired), with warning icon for urgent/expired tokens, (5) Shows remaining days and formatted expiration date using date-fns with French locale. This helps users proactively identify and renew expiring tokens before they cause publishing failures. Files modified: `shared/schema.ts`, `server/routes.ts`, `client/src/pages/pages.tsx`.
- **Story Text Overlay Minimum Font Size Reduction**: Reduced minimum font size in adaptive text overlay system from 24px to 12px for both Instagram and Facebook stories. This allows significantly more text to fit within the story text box (bottom 25% of image) by enabling smaller font sizes when needed. The adaptive sizing algorithm now scales between 12-72px, reducing in 2px increments until text fits or reaches the new 12px minimum. Modified both server-side image generation (`server/services/imageProcessor.ts`) and client-side preview (`client/src/components/preview-modal.tsx`) to maintain consistency between preview and published stories.
- **Facebook Story Preview with Text Overlay**: Added preview support for Facebook Story format in PreviewModal, matching the existing Instagram Story preview functionality. The unified `renderStory()` function now handles both platforms with platform-specific icons (blue Facebook icon vs. gradient Instagram icon) while maintaining identical text overlay rendering with adaptive font sizing (12-72px), semi-transparent background, and centered text display. Both Facebook Story and Instagram Story previews now accurately show how published stories will appear with text burned into the image.
- **Story Text Overlay with Adaptive Sizing**: Implemented automatic text overlay on Instagram and Facebook stories with intelligent font size adaptation. When publishing a story with text content, the system now generates a composite image with the text burned into the bottom 25% of the image. Features: (1) Adaptive font sizing (originally 24-72px, now 12-72px) that automatically adjusts based on text length to fit within the text box, (2) Aspect ratio preservation for source images (centered with black letterboxing if needed to maintain 9:16 story format), (3) Automatic text wrapping with line breaks on word boundaries, (4) Ellipsis truncation for extremely long text that exceeds available space, (5) Semi-transparent black background (60% opacity) behind text for readability. Implementation uses Canvas (node-canvas) for image composition in `imageProcessor.addTextToStoryImage()`, with the generated image uploaded to Cloudinary in a dedicated `social-flow/stories` folder before publication. This ensures text appears correctly on published stories since Facebook/Instagram APIs don't support text overlays natively. The text box reserves 25% of the image height with 40px padding, and font size reduces in 2px increments until text fits or reaches the minimum.
- **AI Assistant Model Loading Fix**: Fixed bug preventing non-admin users from accessing the AI assistant feature. Problem: The AI chat component (`ai-chat.tsx`) was querying `/api/ai/models` which requires admin privileges (`requireAdmin`), causing model list to fail loading for regular users. Solution: Changed the component to use `/api/openrouter/models` endpoint instead, which only requires authentication (`requireAuth`). Updated data structure access from `modelsData?.models` to `modelsData?.data` to match the OpenRouter API response format `{ data: OpenRouterModel[] }`. This fix enables all authenticated users to load the model list and use the AI assistant feature. The endpoint `/api/openrouter/models` was already being used in Settings page for the same purpose, ensuring consistency across the application.
### November 5, 2025
- **New Post Media Grid Optimization**: Optimized the "Nouvelle publication" media grid for better performance and usability. Changes: (1) Limited display to 12 most recent media items sorted by createdAt descending, (2) Removed infinite scroll functionality, (3) Implemented useMemo with spread copy to prevent React Query cache mutation during sorting, (4) Added conditional rendering for videos using `<video>` elements with `preload="metadata"` instead of broken `<img>` tags to display proper video thumbnails. This ensures faster page load, prevents cache corruption, and provides visual previews for both images and videos.
- **Mobile Calendar Visual Separator**: Added visual separator between future and past dates in mobile calendar list view. A horizontal divider with centered text "PUBLICATIONS PASSÉES" now appears before the first past date when both future/today and past dates exist. The separator uses gray horizontal lines on both sides of uppercase text, providing clear visual distinction between upcoming posts and past posts. The separator only appears when transitioning from future to past dates - it does not show when only past or only future dates exist.
- **Mobile Calendar List Sorting Fix**: Fixed illogical date ordering in mobile calendar list view. Problem: After displaying today's date first, remaining dates were sorted using simple localeCompare, mixing past and future dates together. Solution: Implemented intelligent chronological sorting in `calendar-list-view.tsx` with three-tier logic: (1) Today's date always appears first, (2) Future dates display in ascending chronological order (soonest first), (3) Past dates appear last in descending order (most recent first). The sorting uses ISO date string comparison (yyyy-MM-dd) which preserves chronological order. This provides intuitive navigation where users see today, then upcoming posts in order, then recent past posts.
### October 31, 2025
- **Android Video Upload Fix**: Fixed critical bug preventing video uploads on Android devices. Problem: React-dropzone only accepted `.mp4` and `.mov` formats, but Android browsers often use different video formats like `.webm`, `.3gp`, `.mkv`. Solution: Extended accepted video formats in both `new-post.tsx` and `media-upload.tsx` to include `.webm`, `.3gp`, `.3gpp`, `.mkv`, `.avi` in addition to `.mp4` and `.mov`. Also added `.webp` support for images. Implemented diagnostic console logging to capture file name, MIME type, size, and video detection. Added explicit file size validation (50 MB max) with user-friendly error messages showing actual file size. Fixed camera input reset bug where oversized file rejection prevented subsequent capture attempts - now properly resets input (`e.target.value = ''`) after both errors and successful uploads. Photo uploads on Android already worked; this fix specifically targets video capture compatibility.
## System Architecture
### Frontend Architecture
The frontend is built with React and TypeScript, using Vite for bundling and Wouter for routing. State management primarily uses TanStack Query. UI components are built with Radix UI primitives and shadcn/ui, styled with Tailwind CSS, emphasizing a component-based, responsive, mobile-first design with real-time data synchronization. Dark mode is the default theme.
The frontend is built with React and TypeScript, leveraging Vite for bundling and Wouter for routing. State management is primarily handled by TanStack Query. UI components are constructed using Radix UI primitives and shadcn/ui, styled with Tailwind CSS, emphasizing a component-based, responsive, mobile-first design with real-time data synchronization. Dark mode is the default theme.
### Backend Architecture
The backend runs on Node.js with Express.js, fully implemented in TypeScript. It follows a RESTful API design with clear endpoints for authentication, user management, AI content generation, media handling, social page management, and post scheduling. Multer is used for file uploads, integrating with Cloudinary. Node-cron handles background processing for scheduled post publication. Architectural patterns include a service layer, storage abstraction, and a middleware-based request processing pipeline. Session management is handled by `express-session` with `connect-pg-simple`.
The backend is developed in Node.js with Express.js, entirely in TypeScript. It adheres to a RESTful API design with dedicated endpoints for authentication, user management, AI content generation, media handling, social page management, and post scheduling. Multer is utilized for file uploads, integrated with Cloudinary. Node-cron manages background processes for scheduled post publication. The architecture incorporates a service layer, storage abstraction, and a middleware-based request processing pipeline. Session management is handled by `express-session` with `connect-pg-simple`.
### Database Architecture
PostgreSQL is the chosen database, utilizing Drizzle ORM for type-safe operations and Neon serverless driver for WebSocket support. The schema includes core tables for `users`, `cloudinary_config`, `social_pages`, `media`, `posts`, `post_media`, `scheduled_posts`, and `ai_generations`. Drizzle Kit manages schema migrations.
PostgreSQL serves as the database, interfaced using Drizzle ORM for type-safe operations and Neon serverless driver for WebSocket support. The schema includes tables for `users`, `cloudinary_config`, `social_pages`, `media`, `posts`, `post_media`, `scheduled_posts`, and `ai_generations`. Drizzle Kit is used for schema migrations.
### Media Processing Pipeline
The platform leverages Cloudinary for cloud-based image and video storage and transformation. Original files are uploaded, and for publishing, the original image URL is used. Cloudinary generates transformation URLs exclusively for UI preview purposes.
Cloudinary is central to the platform's media processing, providing cloud-based storage, transformations, and CDN capabilities for images and videos. Original files are uploaded to Cloudinary, and their URLs are used for publishing. Transformation URLs are primarily generated by Cloudinary for UI preview purposes. The platform also includes server-side image processing using Sharp for features like logo overlay and text rendering for stories.
### Authentication & Authorization
Authentication uses Passport.js with local strategy and bcrypt for password hashing. User roles (`admin`, `user`) control access. AI text generation is available to all authenticated users using shared admin OpenRouter configuration. Session management is via `express-session` with HTTP-only cookies and a 7-day duration. Routes are protected on both backend (middleware `requireAuth`, `requireAdmin`) and frontend (`ProtectedRoute` component).
Authentication relies on Passport.js with a local strategy and bcrypt for password hashing. User roles (`admin`, `user`) dictate access levels. AI text generation is accessible to all authenticated users via a shared admin OpenRouter configuration. Session management is handled by `express-session` with HTTP-only cookies, set for a 7-day duration. Both backend (middleware `requireAuth`, `requireAdmin`) and frontend (`ProtectedRoute` component) implement route protection.
### UI/UX Decisions
The UI is built with Radix UI and shadcn/ui, leveraging Tailwind CSS. Key design decisions include a component-based architecture, custom path aliases, and a responsive, mobile-first approach. The platform supports multi-photo carousel posts with drag-and-drop reordering, and a PreviewModal offers realistic rendering of posts across Facebook and Instagram formats. The calendar view is responsive, with a grid for desktop and an expandable list for mobile, allowing editing and deletion of scheduled posts. Infinite scroll is implemented in the media library and new post pages for performance, using IntersectionObserver.
The UI uses Radix UI and shadcn/ui, styled with Tailwind CSS. Design principles include a component-based architecture, custom path aliases, and a responsive, mobile-first approach. Features like multi-photo carousel posts with drag-and-drop reordering and a PreviewModal offer realistic rendering across Facebook and Instagram. The calendar view is responsive, featuring a grid for desktop and an expandable list for mobile, enabling editing and deletion of scheduled posts. Infinite scroll, powered by IntersectionObserver, is implemented in the media library and new post pages. A company logo overlay system has been integrated into the image editor, providing options for positioning, size, and opacity.
## External Dependencies
### AI Content Generation
- **OpenRouter API**: Used for AI text generation (e.g., Anthropic Claude 3.5 Sonnet).
### Cloud Storage
- **Cloudinary**: Provides cloud-based image and video storage, transformations, and CDN.
- **Cloudinary**: Cloud-based image and video storage, transformations, and CDN.
### Social Media APIs
- **Facebook Graph API**: Used for publishing posts to Facebook pages, including multi-photo carousel and video support.
- **Instagram Graph API**: Used for single photo/video posts.
- **Facebook Graph API**: For publishing posts to Facebook pages, including multi-photo carousel and video.
- **Instagram Graph API**: For single photo/video posts.
### Database Service
- **Neon Serverless PostgreSQL**: Primary database service.
### UI Component Libraries
- **Radix UI**: Accessible UI primitives.
- **React Hook Form**: Form state management with validation.
- **Zod**: Schema validation.
+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(),
});