mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
9 files changed
+704
-44
No files matched your search
@@ -42,10 +42,6 @@ externalPort = 6000
|
||||
localPort = 43245
|
||||
externalPort = 3001
|
||||
|
||||
[[ports]]
|
||||
localPort = 45355
|
||||
externalPort = 6800
|
||||
|
||||
[[ports]]
|
||||
localPort = 46681
|
||||
externalPort = 5173
|
||||
|
||||
@@ -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<PreviewFormat>('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 = () => (
|
||||
<div className="bg-white rounded-lg overflow-hidden border border-gray-200 max-w-lg mx-auto">
|
||||
{/* Header */}
|
||||
<div className="p-3 flex items-center gap-3 border-b border-gray-200">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<Facebook className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-sm text-gray-900">Votre Page</div>
|
||||
<div className="text-xs text-gray-500">À l'instant</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
{postText && (
|
||||
<div className="p-3 text-sm text-gray-900 whitespace-pre-wrap">
|
||||
{postText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Media Carousel */}
|
||||
{orderedMedia.length > 0 && (
|
||||
<div className="relative bg-black">
|
||||
<img
|
||||
src={getMediaUrl(orderedMedia[currentPhotoIndex])}
|
||||
alt={`Photo ${currentPhotoIndex + 1}`}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
{orderedMedia.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prevPhoto}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 bg-white/90 rounded-full flex items-center justify-center hover:bg-white transition-colors"
|
||||
data-testid="button-prev-photo"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextPhoto}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 bg-white/90 rounded-full flex items-center justify-center hover:bg-white transition-colors"
|
||||
data-testid="button-next-photo"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-1.5">
|
||||
{orderedMedia.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => goToPhoto(idx)}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
idx === currentPhotoIndex ? 'bg-white' : 'bg-white/50'
|
||||
}`}
|
||||
data-testid={`button-photo-indicator-${idx}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-3 flex items-center gap-4 border-t border-gray-200">
|
||||
<Heart className="w-5 h-5 text-gray-600" />
|
||||
<MessageCircle className="w-5 h-5 text-gray-600" />
|
||||
<Share2 className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderInstagramFeed = () => (
|
||||
<div className="bg-white rounded-lg overflow-hidden border border-gray-200 max-w-lg mx-auto">
|
||||
{/* Header */}
|
||||
<div className="p-3 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-yellow-400 via-pink-500 to-purple-600 flex items-center justify-center">
|
||||
<Instagram className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-sm text-gray-900">votre_page</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media Carousel */}
|
||||
{orderedMedia.length > 0 && (
|
||||
<div className="relative bg-black aspect-square">
|
||||
<img
|
||||
src={getMediaUrl(orderedMedia[currentPhotoIndex])}
|
||||
alt={`Photo ${currentPhotoIndex + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{orderedMedia.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prevPhoto}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 bg-black/50 rounded-full flex items-center justify-center hover:bg-black/70 transition-colors"
|
||||
data-testid="button-prev-photo"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextPhoto}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 bg-black/50 rounded-full flex items-center justify-center hover:bg-black/70 transition-colors"
|
||||
data-testid="button-next-photo"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex gap-1">
|
||||
{orderedMedia.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => goToPhoto(idx)}
|
||||
className={`h-1 flex-1 transition-colors rounded ${
|
||||
idx === currentPhotoIndex ? 'bg-white' : 'bg-white/50'
|
||||
}`}
|
||||
style={{ width: '40px' }}
|
||||
data-testid={`button-photo-indicator-${idx}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-3 flex items-center gap-4">
|
||||
<Heart className="w-6 h-6" />
|
||||
<MessageCircle className="w-6 h-6" />
|
||||
<Share2 className="w-6 h-6" />
|
||||
<Bookmark className="w-6 h-6 ml-auto" />
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
{postText && (
|
||||
<div className="px-3 pb-3">
|
||||
<span className="font-semibold text-sm">votre_page</span>
|
||||
<span className="text-sm ml-2">{postText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderInstagramStory = () => (
|
||||
<div className="bg-black rounded-lg overflow-hidden max-w-sm mx-auto" style={{ aspectRatio: '9/16' }}>
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 p-3 flex items-center gap-3 z-10">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-yellow-400 via-pink-500 to-purple-600 flex items-center justify-center">
|
||||
<Instagram className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-sm text-white drop-shadow-lg">votre_page</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media */}
|
||||
{orderedMedia.length > 0 && (
|
||||
<div className="relative h-full">
|
||||
<img
|
||||
src={getMediaUrl(orderedMedia[currentPhotoIndex])}
|
||||
alt={`Story ${currentPhotoIndex + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{orderedMedia.length > 1 && (
|
||||
<>
|
||||
<div className="absolute top-3 left-3 right-3 flex gap-1">
|
||||
{orderedMedia.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => goToPhoto(idx)}
|
||||
className={`h-1 flex-1 transition-colors rounded-full ${
|
||||
idx === currentPhotoIndex ? 'bg-white' : 'bg-white/50'
|
||||
}`}
|
||||
data-testid={`button-photo-indicator-${idx}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={prevPhoto}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-10 h-10 bg-black/30 rounded-full flex items-center justify-center hover:bg-black/50 transition-colors"
|
||||
data-testid="button-prev-photo"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextPhoto}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-10 h-10 bg-black/30 rounded-full flex items-center justify-center hover:bg-black/50 transition-colors"
|
||||
data-testid="button-next-photo"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* Text overlay */}
|
||||
{postText && (
|
||||
<div className="absolute bottom-20 left-0 right-0 p-4">
|
||||
<div className="bg-black/50 backdrop-blur-sm rounded-lg p-3">
|
||||
<p className="text-white text-sm text-center">{postText}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Prévisualisation de la publication</DialogTitle>
|
||||
<DialogDescription>
|
||||
Visualisez votre publication avant de la publier
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Format selector */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">Format de prévisualisation:</label>
|
||||
<Select value={previewFormat} onValueChange={(value: PreviewFormat) => {
|
||||
setPreviewFormat(value);
|
||||
setCurrentPhotoIndex(0);
|
||||
}}>
|
||||
<SelectTrigger className="w-64" data-testid="select-preview-format">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="facebook-feed" data-testid="option-facebook-feed">
|
||||
Feed Facebook (1200x630)
|
||||
</SelectItem>
|
||||
<SelectItem value="instagram-feed" data-testid="option-instagram-feed">
|
||||
Feed Instagram (1080x1080)
|
||||
</SelectItem>
|
||||
<SelectItem value="instagram-story" data-testid="option-instagram-story">
|
||||
Story Instagram (1080x1920)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="border rounded-lg p-6 bg-gray-50">
|
||||
{previewFormat === 'facebook-feed' && renderFacebookFeed()}
|
||||
{previewFormat === 'instagram-feed' && renderInstagramFeed()}
|
||||
{previewFormat === 'instagram-story' && renderInstagramStory()}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-testid="button-cancel-preview"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onPublish}
|
||||
disabled={isPublishing}
|
||||
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90"
|
||||
data-testid="button-publish-from-preview"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{isPublishing ? 'Publication...' : 'Publier'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+215
-29
@@ -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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-primary ring-2 ring-primary'
|
||||
: 'border-transparent hover:border-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full h-full"
|
||||
data-testid={`button-select-media-${media.id}`}
|
||||
>
|
||||
<img
|
||||
src={media.originalUrl}
|
||||
alt={media.fileName}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
{isSelected && (
|
||||
<>
|
||||
<div className="absolute top-1 right-1 w-6 h-6 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-xs font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="absolute top-1 left-1 w-8 h-8 bg-black/50 rounded-full flex items-center justify-center cursor-move hover:bg-black/70 transition-colors"
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NewPost() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
@@ -23,12 +104,13 @@ export default function NewPost() {
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [productInfo, setProductInfo] = useState('');
|
||||
const [selectedMedia, setSelectedMedia] = useState<string | null>(null);
|
||||
const [selectedMedia, setSelectedMedia] = useState<string[]>([]);
|
||||
const [selectedPages, setSelectedPages] = useState<string[]>([]);
|
||||
const [scheduledDate, setScheduledDate] = useState('');
|
||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [postText, setPostText] = useState('');
|
||||
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||
|
||||
const { data: pages = [] } = useQuery<SocialPage[]>({
|
||||
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 (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
@@ -298,26 +410,90 @@ export default function NewPost() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{mediaList.map((media) => (
|
||||
<button
|
||||
key={media.id}
|
||||
onClick={() => setSelectedMedia(media.id)}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${
|
||||
selectedMedia === media.id
|
||||
? 'border-primary ring-2 ring-primary'
|
||||
: 'border-transparent hover:border-muted-foreground'
|
||||
}`}
|
||||
data-testid={`button-select-media-${media.id}`}
|
||||
>
|
||||
<img
|
||||
src={media.originalUrl}
|
||||
alt={media.fileName}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{selectedMedia.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2">
|
||||
Photos sélectionnées ({selectedMedia.length}/10)
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedMedia}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-2 p-3 bg-accent/20 rounded-lg border border-accent">
|
||||
{selectedMedia.map((mediaId, index) => {
|
||||
const media = mediaList.find(m => m.id === mediaId);
|
||||
if (!media) return null;
|
||||
|
||||
return (
|
||||
<SortableMediaItem
|
||||
key={media.id}
|
||||
media={media}
|
||||
index={index}
|
||||
isSelected={true}
|
||||
onToggle={() => {
|
||||
setSelectedMedia(prev => prev.filter(id => id !== media.id));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2">
|
||||
Toutes les photos
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{mediaList.map((media) => {
|
||||
const isSelected = selectedMedia.includes(media.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={media.id}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
// Remove from selection
|
||||
setSelectedMedia(prev => prev.filter(id => id !== media.id));
|
||||
} else if (selectedMedia.length < 10) {
|
||||
// Add to selection (max 10)
|
||||
setSelectedMedia(prev => [...prev, media.id]);
|
||||
} else {
|
||||
toast({
|
||||
title: "Limite atteinte",
|
||||
description: "Maximum 10 photos par publication",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-primary ring-2 ring-primary opacity-50'
|
||||
: 'border-transparent hover:border-muted-foreground'
|
||||
}`}
|
||||
data-testid={`button-select-media-${media.id}`}
|
||||
>
|
||||
<img
|
||||
src={media.originalUrl}
|
||||
alt={media.fileName}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 bg-primary/10 flex items-center justify-center">
|
||||
<div className="text-xs font-semibold text-primary">Sélectionnée</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -501,19 +677,29 @@ export default function NewPost() {
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
onClick={handleCreatePost}
|
||||
onClick={() => setPreviewModalOpen(true)}
|
||||
disabled={createPostMutation.isPending}
|
||||
className="w-full bg-gradient-to-r from-primary to-secondary hover:opacity-90 shadow-lg"
|
||||
size="lg"
|
||||
data-testid="button-create-post"
|
||||
data-testid="button-preview-post"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{createPostMutation.isPending ? 'Création...' : 'Créer la publication'}
|
||||
Prévisualiser et publier
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<PreviewModal
|
||||
open={previewModalOpen}
|
||||
onOpenChange={setPreviewModalOpen}
|
||||
postText={postText}
|
||||
selectedMedia={selectedMedia}
|
||||
mediaList={mediaList}
|
||||
onPublish={handleCreatePost}
|
||||
isPublishing={createPostMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Generated
+56
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
+38
-8
@@ -557,11 +557,39 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
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<Server> {
|
||||
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
|
||||
|
||||
+2
-2
@@ -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<PostMedia[]> {
|
||||
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
|
||||
|
||||
@@ -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<typeof insertUserSchema>;
|
||||
@@ -288,6 +293,7 @@ export type InsertOpenrouterConfig = z.infer<typeof insertOpenrouterConfigSchema
|
||||
export type UpdateOpenrouterConfig = z.infer<typeof updateOpenrouterConfigSchema>;
|
||||
|
||||
export type PostMedia = typeof postMedia.$inferSelect;
|
||||
export type InsertPostMedia = z.infer<typeof insertPostMediaSchema>;
|
||||
|
||||
export type UserPagePermission = typeof userPagePermissions.$inferSelect;
|
||||
export type InsertUserPagePermission = z.infer<typeof insertUserPagePermissionSchema>;
|
||||
Reference in new issue
Block a user