diff --git a/.replit b/.replit index b321948..d51ca2b 100644 --- a/.replit +++ b/.replit @@ -42,10 +42,6 @@ externalPort = 6000 localPort = 43245 externalPort = 3001 -[[ports]] -localPort = 45355 -externalPort = 6800 - [[ports]] localPort = 46681 externalPort = 5173 diff --git a/client/src/components/preview-modal.tsx b/client/src/components/preview-modal.tsx new file mode 100644 index 0000000..5e1f55a --- /dev/null +++ b/client/src/components/preview-modal.tsx @@ -0,0 +1,335 @@ +import { useState, useEffect } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { ChevronLeft, ChevronRight, Send, Facebook, Instagram, Heart, MessageCircle, Share2, Bookmark } from 'lucide-react'; +import type { Media } from '@shared/schema'; + +interface PreviewModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + postText: string; + selectedMedia: string[]; + mediaList: Media[]; + onPublish: () => void; + isPublishing: boolean; +} + +type PreviewFormat = 'facebook-feed' | 'instagram-feed' | 'instagram-story'; + +export function PreviewModal({ + open, + onOpenChange, + postText, + selectedMedia, + mediaList, + onPublish, + isPublishing +}: PreviewModalProps) { + const [previewFormat, setPreviewFormat] = useState('facebook-feed'); + const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0); + + const orderedMedia = selectedMedia + .map(id => mediaList.find(m => m.id === id)) + .filter((m): m is Media => m !== undefined); + + // Reset photo index when modal opens or media changes + useEffect(() => { + if (open) { + setCurrentPhotoIndex(0); + } + }, [open, selectedMedia.length]); + + const nextPhoto = () => { + setCurrentPhotoIndex((prev) => (prev + 1) % orderedMedia.length); + }; + + const prevPhoto = () => { + setCurrentPhotoIndex((prev) => (prev - 1 + orderedMedia.length) % orderedMedia.length); + }; + + const goToPhoto = (index: number) => { + setCurrentPhotoIndex(index); + }; + + const getMediaUrl = (media: Media) => { + if (previewFormat === 'facebook-feed') { + return media.facebookFeedUrl || media.originalUrl; + } else if (previewFormat === 'instagram-feed') { + return media.instagramFeedUrl || media.originalUrl; + } else { + return media.instagramStoryUrl || media.originalUrl; + } + }; + + const renderFacebookFeed = () => ( +
+ {/* Header */} +
+
+ +
+
+
Votre Page
+
À l'instant
+
+
+ + {/* Text */} + {postText && ( +
+ {postText} +
+ )} + + {/* Media Carousel */} + {orderedMedia.length > 0 && ( +
+ {`Photo + {orderedMedia.length > 1 && ( + <> + + +
+ {orderedMedia.map((_, idx) => ( +
+ + )} +
+ )} + + {/* Actions */} +
+ + + +
+
+ ); + + const renderInstagramFeed = () => ( +
+ {/* Header */} +
+
+ +
+
+
votre_page
+
+
+ + {/* Media Carousel */} + {orderedMedia.length > 0 && ( +
+ {`Photo + {orderedMedia.length > 1 && ( + <> + + +
+ {orderedMedia.map((_, idx) => ( +
+ + )} +
+ )} + + {/* Actions */} +
+ + + + +
+ + {/* Text */} + {postText && ( +
+ votre_page + {postText} +
+ )} +
+ ); + + const renderInstagramStory = () => ( +
+ {/* Header */} +
+
+ +
+
+
votre_page
+
+
+ + {/* Media */} + {orderedMedia.length > 0 && ( +
+ {`Story + {orderedMedia.length > 1 && ( + <> +
+ {orderedMedia.map((_, idx) => ( +
+ + + + )} + {/* Text overlay */} + {postText && ( +
+
+

{postText}

+
+
+ )} +
+ )} +
+ ); + + return ( + + + + Prévisualisation de la publication + + Visualisez votre publication avant de la publier + + + +
+ {/* Format selector */} +
+ + +
+ + {/* Preview */} +
+ {previewFormat === 'facebook-feed' && renderFacebookFeed()} + {previewFormat === 'instagram-feed' && renderInstagramFeed()} + {previewFormat === 'instagram-story' && renderInstagramStory()} +
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/client/src/pages/new-post.tsx b/client/src/pages/new-post.tsx index 77f7ca5..35f2d7e 100644 --- a/client/src/pages/new-post.tsx +++ b/client/src/pages/new-post.tsx @@ -1,8 +1,25 @@ import { useState, useCallback, useRef } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { useLocation } from "wouter"; -import { Send, Sparkles, Image as ImageIcon, Calendar, Upload, Camera } from "lucide-react"; +import { Send, Sparkles, Image as ImageIcon, Calendar, Upload, Camera, GripVertical } from "lucide-react"; import { useDropzone } from "react-dropzone"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + rectSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import Sidebar from "@/components/sidebar"; import TopBar from "@/components/topbar"; import { Button } from "@/components/ui/button"; @@ -15,6 +32,70 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { useToast } from "@/hooks/use-toast"; import { apiRequest, queryClient } from "@/lib/queryClient"; import type { SocialPage, Media } from "@shared/schema"; +import { PreviewModal } from "@/components/preview-modal"; + +function SortableMediaItem({ + media, + index, + isSelected, + onToggle +}: { + media: Media; + index: number; + isSelected: boolean; + onToggle: () => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id: media.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ + {isSelected && ( + <> +
+ {index + 1} +
+
+ +
+ + )} +
+ ); +} export default function NewPost() { const [sidebarOpen, setSidebarOpen] = useState(false); @@ -23,12 +104,13 @@ export default function NewPost() { const cameraInputRef = useRef(null); const [productInfo, setProductInfo] = useState(''); - const [selectedMedia, setSelectedMedia] = useState(null); + const [selectedMedia, setSelectedMedia] = useState([]); const [selectedPages, setSelectedPages] = useState([]); const [scheduledDate, setScheduledDate] = useState(''); const [generatedVariants, setGeneratedVariants] = useState([]); const [postText, setPostText] = useState(''); const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed'); + const [previewModalOpen, setPreviewModalOpen] = useState(false); const { data: pages = [] } = useQuery({ queryKey: ['/api/pages'], @@ -51,7 +133,18 @@ export default function NewPost() { }, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ["/api/media"] }); - setSelectedMedia(data.id); + // Add to selected media array if not already at max (10) + setSelectedMedia(prev => { + if (prev.length >= 10) { + toast({ + title: "Limite atteinte", + description: "Maximum 10 photos par publication", + variant: "destructive", + }); + return prev; + } + return [...prev, data.id]; + }); toast({ title: "Succès", description: "Photo téléchargée avec succès", @@ -164,10 +257,10 @@ export default function NewPost() { return; } - if ((postType === 'story' || postType === 'both') && !selectedMedia) { + if ((postType === 'story' || postType === 'both') && selectedMedia.length === 0) { toast({ title: "Média requis", - description: "Les stories nécessitent une image ou vidéo", + description: "Les stories nécessitent au moins une image ou vidéo", variant: "destructive", }); return; @@ -176,7 +269,7 @@ export default function NewPost() { createPostMutation.mutate({ content: postText, scheduledFor: scheduledDate || undefined, - mediaId: selectedMedia || undefined, + mediaIds: selectedMedia.length > 0 ? selectedMedia : undefined, pageIds: selectedPages, postType, }); @@ -197,6 +290,25 @@ export default function NewPost() { } }; + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + setSelectedMedia((items) => { + const oldIndex = items.indexOf(active.id as string); + const newIndex = items.indexOf(over.id as string); + return arrayMove(items, oldIndex, newIndex); + }); + } + }; + return (
{sidebarOpen && ( @@ -298,26 +410,90 @@ export default function NewPost() {
) : ( -
- {mediaList.map((media) => ( - - ))} -
+ <> + {selectedMedia.length > 0 && ( +
+
+ Photos sélectionnées ({selectedMedia.length}/10) +
+ + +
+ {selectedMedia.map((mediaId, index) => { + const media = mediaList.find(m => m.id === mediaId); + if (!media) return null; + + return ( + { + setSelectedMedia(prev => prev.filter(id => id !== media.id)); + }} + /> + ); + })} +
+
+
+
+ )} +
+ Toutes les photos +
+
+ {mediaList.map((media) => { + const isSelected = selectedMedia.includes(media.id); + + return ( + + ); + })} +
+ )} @@ -501,19 +677,29 @@ export default function NewPost() { + + ); } diff --git a/package-lock.json b/package-lock.json index 9961c5f..b32a42f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.10.0", "@jridgewell/trace-mapping": "^0.3.25", "@neondatabase/serverless": "^0.10.4", @@ -418,6 +421,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", diff --git a/package.json b/package.json index fe661ff..81dab0c 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "db:push": "drizzle-kit push" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.10.0", "@jridgewell/trace-mapping": "^0.3.25", "@neondatabase/serverless": "^0.10.4", diff --git a/replit.md b/replit.md index 80479af..8c98c5d 100644 --- a/replit.md +++ b/replit.md @@ -10,6 +10,8 @@ Social Flow is a comprehensive social media automation platform designed to stre - Multi-page social media account management (Facebook & Instagram) - AI-powered content generation using OpenRouter API - Automated media processing and format optimization +- **Multi-photo carousel posts** (up to 10 photos per post with drag & drop reordering) +- **Preview modal** with realistic Facebook/Instagram rendering before publishing - Post scheduling with automated publication - Media library management - Dashboard analytics and activity tracking @@ -121,6 +123,7 @@ Core Tables: - `social_pages` - Connected Facebook/Instagram pages with access tokens - `media` - Uploaded media files with Cloudinary public IDs and transformation URLs - `posts` - Post content with AI generation tracking and status +- `post_media` - **NEW**: Junction table linking posts to multiple media with `displayOrder` field for carousel ordering - `scheduled_posts` - Scheduled publication queue with page assignments - `ai_generations` - History of AI-generated content @@ -222,4 +225,49 @@ Core Tables: - Zod: Schema validation for forms and API data - date-fns: Date manipulation and formatting - react-dropzone: Drag-and-drop file uploads -- Lucide React: Icon library \ No newline at end of file +- Lucide React: Icon library +- **@dnd-kit**: Drag and drop library for photo carousel reordering + +## Recent Changes (October 2025) + +### Multi-Photo Carousel Feature + +**Overview**: Support for creating posts with up to 10 photos in a carousel format with drag & drop reordering and preview modal. + +**Database Changes**: +- Added `post_media` table with `displayOrder` integer field for ordered media +- Posts can now have multiple photos via `post_media` junction table +- API accepts array of media IDs with display order + +**Frontend Components**: + +**New Post Page** (`/new-post`): +- **Photo Selection**: "Photos sélectionnées" section shows selected photos with order numbers (1, 2, 3...) +- **Drag & Drop**: Uses @dnd-kit/sortable for reordering selected photos + - Drag handle (GripVertical icon) on each selected photo + - Visual order indicators on each photo + - Separate section for selected vs. all photos +- **Limit**: Maximum 10 photos per post +- **Preview Button**: Opens PreviewModal instead of immediate publish + +**PreviewModal Component** (`client/src/components/preview-modal.tsx`): +- **Realistic Rendering**: Shows post exactly as it will appear on Facebook/Instagram +- **Format Switching**: Toggle between: + - Feed Facebook (1200x630px) - Horizontal carousel + - Feed Instagram (1080x1080px) - Square carousel + - Story Instagram (1080x1920px) - Vertical carousel +- **Carousel Navigation**: + - Next/Previous buttons (ChevronLeft/ChevronRight) + - Clickable photo indicators (dots for Facebook, bars for Instagram) + - Shows current photo index +- **Publish Integration**: Publish button in modal triggers post creation + +**API Changes**: +- `POST /api/posts` accepts `mediaIds` parameter as: + - Simple string array (using index as order): `["id1", "id2", "id3"]` + - Or array of objects with displayOrder: `[{mediaId: "id1", displayOrder: 0}, ...]` +- Backwards compatible with single media posts + +**Storage Layer**: +- `createPost` method creates post_media entries with displayOrder +- Bulk insert of media-post relationships with proper ordering \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index f92ba5a..28c51aa 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -557,11 +557,39 @@ export async function registerRoutes(app: Express): Promise { try { const user = req.user as User; const userId = user.id; - const { pageIds, postType, mediaId, ...postFields } = req.body; + const { pageIds, postType, mediaId, mediaIds, ...postFields } = req.body; + + // Convert mediaIds to standardized format: array of { mediaId, displayOrder } + let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = []; + + if (mediaIds && Array.isArray(mediaIds)) { + // New format: array of objects or strings + finalMediaItems = mediaIds.map((item: any, index: number) => { + if (typeof item === 'string') { + // Legacy array of strings: use index as displayOrder + return { mediaId: item, displayOrder: index }; + } else if (item.mediaId) { + // New format: object with mediaId and displayOrder + return { + mediaId: item.mediaId, + displayOrder: item.displayOrder ?? index, + }; + } + throw new Error("Invalid media format"); + }); + } else if (mediaId) { + // Legacy single mediaId + finalMediaItems = [{ mediaId, displayOrder: 0 }]; + } + + // Validate max 10 photos + if (finalMediaItems.length > 10) { + return res.status(400).json({ error: "Maximum 10 photos autorisées par publication" }); + } // Validate that stories require media - if ((postType === 'story' || postType === 'both') && !mediaId) { - return res.status(400).json({ error: "Les stories nécessitent un média (image ou vidéo)" }); + if ((postType === 'story' || postType === 'both') && finalMediaItems.length === 0) { + return res.status(400).json({ error: "Les stories nécessitent au moins un média (image ou vidéo)" }); } // Convert scheduledFor string to Date if provided @@ -573,12 +601,14 @@ export async function registerRoutes(app: Express): Promise { const postData = insertPostSchema.parse({ ...postFields, userId }); const post = await storage.createPost(postData); - // Link media to post if provided - if (mediaId) { - await db.insert(postMedia).values({ + // Link media to post if provided (with display order) + if (finalMediaItems.length > 0) { + const postMediaValues = finalMediaItems.map(item => ({ postId: post.id, - mediaId: mediaId, - }); + mediaId: item.mediaId, + displayOrder: item.displayOrder, + })); + await db.insert(postMedia).values(postMediaValues); } // Create scheduled posts for each selected page diff --git a/server/storage.ts b/server/storage.ts index 82a7b95..20571bd 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -30,7 +30,7 @@ import { type InsertUserPagePermission, } from "@shared/schema"; import { db } from "./db"; -import { eq, and, gte, lte, desc, isNull } from "drizzle-orm"; +import { eq, and, gte, lte, desc, asc, isNull } from "drizzle-orm"; export interface IStorage { // Users @@ -168,7 +168,7 @@ export class DatabaseStorage implements IStorage { } async getPostMedia(postId: string): Promise { - return await db.select().from(postMedia).where(eq(postMedia.postId, postId)); + return await db.select().from(postMedia).where(eq(postMedia.postId, postId)).orderBy(asc(postMedia.displayOrder)); } // Posts diff --git a/shared/schema.ts b/shared/schema.ts index d9f44cd..2cb6af9 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -79,6 +79,7 @@ export const postMedia = pgTable("post_media", { id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), postId: varchar("post_id").notNull().references(() => posts.id, { onDelete: "cascade" }), mediaId: varchar("media_id").notNull().references(() => media.id, { onDelete: "cascade" }), + displayOrder: integer("display_order").notNull().default(0), }); export const scheduledPosts = pgTable("scheduled_posts", { @@ -260,6 +261,10 @@ export const insertUserPagePermissionSchema = createInsertSchema(userPagePermiss createdAt: true, }); +export const insertPostMediaSchema = createInsertSchema(postMedia).omit({ + id: true, +}); + // Types export type User = typeof users.$inferSelect; export type InsertUser = z.infer; @@ -288,6 +293,7 @@ export type InsertOpenrouterConfig = z.infer; export type PostMedia = typeof postMedia.$inferSelect; +export type InsertPostMedia = z.infer; export type UserPagePermission = typeof userPagePermissions.$inferSelect; export type InsertUserPagePermission = z.infer;