mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 15:08:13 +02:00
8 files changed
+767
-33
No files matched your search
@@ -22,6 +22,14 @@ externalPort = 3003
|
|||||||
localPort = 35357
|
localPort = 35357
|
||||||
externalPort = 3002
|
externalPort = 3002
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 35385
|
||||||
|
externalPort = 6800
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 35573
|
||||||
|
externalPort = 8000
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 37431
|
localPort = 37431
|
||||||
externalPort = 5000
|
externalPort = 5000
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { format, isSameDay, parseISO } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { Edit, Trash2, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { SiFacebook, SiInstagram } from "react-icons/si";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface CalendarListViewProps {
|
||||||
|
scheduledPosts: any[];
|
||||||
|
onEditPost: (post: any) => void;
|
||||||
|
onDeletePost: (postId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarListView({ scheduledPosts, onEditPost, onDeletePost }: CalendarListViewProps) {
|
||||||
|
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Group posts by date
|
||||||
|
const groupedPosts = scheduledPosts.reduce((acc: { [key: string]: any[] }, post) => {
|
||||||
|
if (!post.scheduledAt) return acc;
|
||||||
|
const date = format(parseISO(post.scheduledAt), "yyyy-MM-dd");
|
||||||
|
if (!acc[date]) {
|
||||||
|
acc[date] = [];
|
||||||
|
}
|
||||||
|
acc[date].push(post);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Sort dates
|
||||||
|
const sortedDates = Object.keys(groupedPosts).sort();
|
||||||
|
|
||||||
|
const toggleDate = (date: string) => {
|
||||||
|
const newExpanded = new Set(expandedDates);
|
||||||
|
if (newExpanded.has(date)) {
|
||||||
|
newExpanded.delete(date);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(date);
|
||||||
|
}
|
||||||
|
setExpandedDates(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isToday = (dateStr: string) => {
|
||||||
|
return isSameDay(parseISO(dateStr), new Date());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 px-4 pb-4">
|
||||||
|
{sortedDates.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
Aucune publication programmée
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sortedDates.map((dateStr) => {
|
||||||
|
const posts = groupedPosts[dateStr];
|
||||||
|
const isExpanded = expandedDates.has(dateStr);
|
||||||
|
const dateObj = parseISO(dateStr);
|
||||||
|
const isDateToday = isToday(dateStr);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={dateStr}
|
||||||
|
className={`rounded-xl border ${isDateToday ? 'border-primary/50 bg-primary/5' : 'border-border/50 bg-card'} overflow-hidden`}
|
||||||
|
data-testid={`calendar-list-date-${dateStr}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleDate(dateStr)}
|
||||||
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-muted/30 transition-colors"
|
||||||
|
data-testid={`button-toggle-date-${dateStr}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold text-foreground">
|
||||||
|
{format(dateObj, "EEEE d MMMM yyyy", { locale: fr })}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{posts.length} publication{posts.length > 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isDateToday && (
|
||||||
|
<span className="text-xs bg-primary text-primary-foreground px-2 py-1 rounded-full font-semibold">
|
||||||
|
Aujourd'hui
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-border/50">
|
||||||
|
{posts.map((post: any) => {
|
||||||
|
const time = post.scheduledAt
|
||||||
|
? new Date(post.scheduledAt).toLocaleTimeString('fr-FR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
const isPending = !post.publishedAt;
|
||||||
|
const isPublished = !!post.publishedAt;
|
||||||
|
const pageName = post.page?.pageName || 'Page inconnue';
|
||||||
|
const platform = post.page?.platform || 'facebook';
|
||||||
|
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={post.id}
|
||||||
|
className={`px-4 py-3 border-b border-border/30 last:border-b-0 ${
|
||||||
|
isPending ? 'bg-blue-500/5' : 'bg-green-500/5'
|
||||||
|
}`}
|
||||||
|
data-testid={`calendar-list-post-${post.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-semibold text-lg text-foreground">{time}</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
isPending
|
||||||
|
? 'bg-blue-500/20 text-blue-600'
|
||||||
|
: 'bg-green-500/20 text-green-600'
|
||||||
|
}`}>
|
||||||
|
{isPending ? 'Programmé' : 'Publié'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<PlatformIcon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate">{pageName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEditPost(post)}
|
||||||
|
className="h-11 w-11 p-0"
|
||||||
|
data-testid={`button-edit-post-${post.id}`}
|
||||||
|
title="Modifier"
|
||||||
|
>
|
||||||
|
<Edit className="w-5 h-5 text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDeletePost(post.id)}
|
||||||
|
className="h-11 w-11 p-0"
|
||||||
|
data-testid={`button-delete-post-${post.id}`}
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-5 h-5 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Trash2 } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Trash2, Edit } from "lucide-react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import type { ScheduledPost } from "@shared/schema";
|
import type { ScheduledPost } from "@shared/schema";
|
||||||
|
import { SiFacebook, SiInstagram } from "react-icons/si";
|
||||||
|
import EditScheduledPostDialog from "./edit-scheduled-post-dialog";
|
||||||
|
import CalendarListView from "./calendar-list-view";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
|
||||||
export default function CalendarView() {
|
export default function CalendarView() {
|
||||||
const [currentDate, setCurrentDate] = useState(new Date());
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [selectedPost, setSelectedPost] = useState<any>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
|
||||||
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
||||||
queryKey: ["/api/scheduled-posts"],
|
queryKey: ["/api/scheduled-posts"],
|
||||||
@@ -33,13 +40,19 @@ export default function CalendarView() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDeletePost = (postId: string, e: React.MouseEvent) => {
|
const handleDeletePost = (postId: string, e?: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
if (e) e.stopPropagation();
|
||||||
if (confirm("Êtes-vous sûr de vouloir supprimer cette publication programmée ?")) {
|
if (confirm("Êtes-vous sûr de vouloir supprimer cette publication programmée ?")) {
|
||||||
deletePostMutation.mutate(postId);
|
deletePostMutation.mutate(postId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditPost = (post: any, e?: React.MouseEvent) => {
|
||||||
|
if (e) e.stopPropagation();
|
||||||
|
setSelectedPost(post);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const getPostsForDate = (date: Date) => {
|
const getPostsForDate = (date: Date) => {
|
||||||
return (scheduledPosts || []).filter(post => {
|
return (scheduledPosts || []).filter(post => {
|
||||||
if (!post.scheduledAt) return false;
|
if (!post.scheduledAt) return false;
|
||||||
@@ -137,16 +150,17 @@ export default function CalendarView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
{isDesktop ? (
|
||||||
<div className="grid grid-cols-7 gap-2 mb-2">
|
<div className="p-8">
|
||||||
{daysOfWeek.map((day) => (
|
<div className="grid grid-cols-7 gap-2 mb-2">
|
||||||
<div key={day} className="bg-muted/30 py-3 rounded-lg text-center">
|
{daysOfWeek.map((day) => (
|
||||||
<span className="text-sm font-semibold text-muted-foreground">{day}</span>
|
<div key={day} className="bg-muted/30 py-3 rounded-lg text-center">
|
||||||
</div>
|
<span className="text-sm font-semibold text-muted-foreground">{day}</span>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-2">
|
<div className="grid grid-cols-7 gap-2">
|
||||||
{days.map((day, index) => (
|
{days.map((day, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
@@ -181,10 +195,13 @@ export default function CalendarView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{postsForDay.slice(0, 2).map((post, idx) => {
|
{postsForDay.slice(0, 2).map((post: any, idx) => {
|
||||||
const time = post.scheduledAt ? new Date(post.scheduledAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : '';
|
const time = post.scheduledAt ? new Date(post.scheduledAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : '';
|
||||||
const isPending = !post.publishedAt;
|
const isPending = !post.publishedAt;
|
||||||
const isPublished = !!post.publishedAt;
|
const isPublished = !!post.publishedAt;
|
||||||
|
const pageName = post.page?.pageName || 'Page inconnue';
|
||||||
|
const platform = post.page?.platform || 'facebook';
|
||||||
|
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -199,17 +216,30 @@ export default function CalendarView() {
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-semibold">{time}</div>
|
<div className="font-semibold">{time}</div>
|
||||||
<div className="truncate opacity-90">{post.postType || 'Post'}</div>
|
<div className="flex items-center gap-1 truncate opacity-90">
|
||||||
|
<PlatformIcon className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span className="truncate">{pageName}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isPending && (
|
{isPending && (
|
||||||
<button
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
onClick={(e) => handleDeletePost(post.id, e)}
|
<button
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-red-500/20 rounded"
|
onClick={(e) => handleEditPost(post, e)}
|
||||||
data-testid={`button-delete-post-${post.id}`}
|
className="p-1 hover:bg-blue-500/20 rounded"
|
||||||
title="Supprimer"
|
data-testid={`button-edit-post-${post.id}`}
|
||||||
>
|
title="Modifier"
|
||||||
<Trash2 className="w-3 h-3 text-red-500" />
|
>
|
||||||
</button>
|
<Edit className="w-3 h-3 text-blue-500" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDeletePost(post.id, e)}
|
||||||
|
className="p-1 hover:bg-red-500/20 rounded"
|
||||||
|
data-testid={`button-delete-post-${post.id}`}
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3 text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,17 +257,30 @@ export default function CalendarView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 flex items-center justify-center gap-8 flex-wrap">
|
<div className="mt-8 flex items-center justify-center gap-8 flex-wrap">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-4 h-4 rounded-md bg-blue-500/20 border-2 border-blue-500"></div>
|
<div className="w-4 h-4 rounded-md bg-blue-500/20 border-2 border-blue-500"></div>
|
||||||
<span className="text-sm text-muted-foreground font-medium">Programmé</span>
|
<span className="text-sm text-muted-foreground font-medium">Programmé</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-4 h-4 rounded-md bg-green-500/20 border-2 border-green-500"></div>
|
<div className="w-4 h-4 rounded-md bg-green-500/20 border-2 border-green-500"></div>
|
||||||
<span className="text-sm text-muted-foreground font-medium">Publié</span>
|
<span className="text-sm text-muted-foreground font-medium">Publié</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<CalendarListView
|
||||||
|
scheduledPosts={scheduledPosts}
|
||||||
|
onEditPost={handleEditPost}
|
||||||
|
onDeletePost={handleDeletePost}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EditScheduledPostDialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onOpenChange={setEditDialogOpen}
|
||||||
|
scheduledPost={selectedPost || {}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import type { SocialPage, Media, Post } from "@shared/schema";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Image as ImageIcon, CheckCircle } from "lucide-react";
|
||||||
|
|
||||||
|
interface EditScheduledPostDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
scheduledPost: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditScheduledPostDialog({ open, onOpenChange, scheduledPost }: EditScheduledPostDialogProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [date, setDate] = useState("");
|
||||||
|
const [time, setTime] = useState("");
|
||||||
|
const [pageId, setPageId] = useState("");
|
||||||
|
const [postText, setPostText] = useState("");
|
||||||
|
const [selectedMediaIds, setSelectedMediaIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const { data: pages = [] } = useQuery<SocialPage[]>({
|
||||||
|
queryKey: ["/api/pages"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: postData } = useQuery<{ post: Post; media: Media[] }>({
|
||||||
|
queryKey: ["/api/posts", scheduledPost?.postId],
|
||||||
|
enabled: !!scheduledPost?.postId && open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: allMedia = [] } = useQuery<Media[]>({
|
||||||
|
queryKey: ["/api/media"],
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scheduledPost && open) {
|
||||||
|
const scheduledAt = new Date(scheduledPost.scheduledAt);
|
||||||
|
setDate(format(scheduledAt, "yyyy-MM-dd"));
|
||||||
|
setTime(format(scheduledAt, "HH:mm"));
|
||||||
|
setPageId(scheduledPost.pageId);
|
||||||
|
}
|
||||||
|
}, [scheduledPost, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (postData && open) {
|
||||||
|
setPostText(postData.post.content);
|
||||||
|
setSelectedMediaIds(postData.media.map(m => m.id));
|
||||||
|
}
|
||||||
|
}, [postData, open]);
|
||||||
|
|
||||||
|
const updateScheduledMutation = useMutation({
|
||||||
|
mutationFn: (data: { scheduledAt: string; pageId: string }) =>
|
||||||
|
apiRequest('PATCH', `/api/scheduled-posts/${scheduledPost.id}`, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePostTextMutation = useMutation({
|
||||||
|
mutationFn: (data: { content: string }) =>
|
||||||
|
apiRequest('PATCH', `/api/posts/${scheduledPost?.postId}`, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/posts', scheduledPost?.postId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePostMediaMutation = useMutation({
|
||||||
|
mutationFn: (data: { mediaIds: string[] }) =>
|
||||||
|
apiRequest('PATCH', `/api/posts/${scheduledPost?.postId}/media`, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/posts', scheduledPost?.postId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!date || !time || !pageId) {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Veuillez remplir tous les champs",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scheduledAt = new Date(`${date}T${time}`);
|
||||||
|
|
||||||
|
await updateScheduledMutation.mutateAsync({ scheduledAt: scheduledAt.toISOString(), pageId });
|
||||||
|
|
||||||
|
if (postData && postText !== postData.post.content) {
|
||||||
|
await updatePostTextMutation.mutateAsync({ content: postText });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMediaIds = postData?.media.map(m => m.id) || [];
|
||||||
|
if (JSON.stringify(selectedMediaIds) !== JSON.stringify(currentMediaIds)) {
|
||||||
|
await updatePostMediaMutation.mutateAsync({ mediaIds: selectedMediaIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Modifications enregistrées",
|
||||||
|
description: "Le post a été mis à jour avec succès",
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Impossible de modifier le post",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMediaSelection = (mediaId: string) => {
|
||||||
|
setSelectedMediaIds(prev => {
|
||||||
|
if (prev.includes(mediaId)) {
|
||||||
|
return prev.filter(id => id !== mediaId);
|
||||||
|
} else {
|
||||||
|
if (prev.length >= 10) {
|
||||||
|
toast({
|
||||||
|
title: "Limite atteinte",
|
||||||
|
description: "Maximum 10 photos par publication",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [...prev, mediaId];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPending = updateScheduledMutation.isPending || updatePostTextMutation.isPending || updatePostMediaMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto" data-testid="dialog-edit-scheduled-post">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Modifier le post</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="post-text">Texte du post</Label>
|
||||||
|
<Textarea
|
||||||
|
id="post-text"
|
||||||
|
value={postText}
|
||||||
|
onChange={(e) => setPostText(e.target.value)}
|
||||||
|
placeholder="Écrivez votre texte..."
|
||||||
|
className="min-h-[100px]"
|
||||||
|
data-testid="textarea-post-text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Photos sélectionnées ({selectedMediaIds.length}/10)</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{selectedMediaIds.map((mediaId, index) => {
|
||||||
|
const media = allMedia.find(m => m.id === mediaId);
|
||||||
|
if (!media) return null;
|
||||||
|
return (
|
||||||
|
<div key={mediaId} className="relative group">
|
||||||
|
<img
|
||||||
|
src={media.instagramFeedUrl || media.originalUrl}
|
||||||
|
alt="Selected"
|
||||||
|
className="w-full h-24 object-cover rounded-lg border-2 border-primary"
|
||||||
|
/>
|
||||||
|
<div className="absolute top-1 left-1 bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center text-xs font-bold">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMediaSelection(mediaId)}
|
||||||
|
className="absolute top-1 right-1 bg-destructive text-destructive-foreground rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
data-testid={`button-remove-media-${index}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Toutes les photos</Label>
|
||||||
|
<div className="grid grid-cols-4 gap-2 max-h-[200px] overflow-y-auto p-2 border rounded-lg">
|
||||||
|
{allMedia.map((media) => {
|
||||||
|
const isSelected = selectedMediaIds.includes(media.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={media.id}
|
||||||
|
onClick={() => toggleMediaSelection(media.id)}
|
||||||
|
className="relative group"
|
||||||
|
data-testid={`button-select-media-${media.id}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={media.instagramFeedUrl || media.originalUrl}
|
||||||
|
alt={media.fileName}
|
||||||
|
className={`w-full h-20 object-cover rounded-lg border-2 transition-all ${
|
||||||
|
isSelected ? 'border-primary' : 'border-border hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute inset-0 bg-primary/20 rounded-lg flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="date">Date</Label>
|
||||||
|
<input
|
||||||
|
id="date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
data-testid="input-scheduled-date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="time">Heure</Label>
|
||||||
|
<input
|
||||||
|
id="time"
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => setTime(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
data-testid="input-scheduled-time"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="page">Page</Label>
|
||||||
|
<Select value={pageId} onValueChange={setPageId}>
|
||||||
|
<SelectTrigger data-testid="select-page">
|
||||||
|
<SelectValue placeholder="Sélectionnez une page" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{pages.map((page) => (
|
||||||
|
<SelectItem key={page.id} value={page.id}>
|
||||||
|
{page.pageName} ({page.platform})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
data-testid="button-cancel-edit"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isPending}
|
||||||
|
data-testid="button-save-edit"
|
||||||
|
>
|
||||||
|
{isPending ? "Enregistrement..." : "Enregistrer"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia(query);
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
setMatches(media.matches);
|
||||||
|
|
||||||
|
// Define callback for changes
|
||||||
|
const listener = (e: MediaQueryListEvent) => {
|
||||||
|
setMatches(e.matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modern browsers
|
||||||
|
if (media.addEventListener) {
|
||||||
|
media.addEventListener('change', listener);
|
||||||
|
return () => media.removeEventListener('change', listener);
|
||||||
|
}
|
||||||
|
// Legacy browsers
|
||||||
|
else {
|
||||||
|
media.addListener(listener);
|
||||||
|
return () => media.removeListener(listener);
|
||||||
|
}
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
@@ -270,4 +270,60 @@ Core Tables:
|
|||||||
|
|
||||||
**Storage Layer**:
|
**Storage Layer**:
|
||||||
- `createPost` method creates post_media entries with displayOrder
|
- `createPost` method creates post_media entries with displayOrder
|
||||||
- Bulk insert of media-post relationships with proper ordering
|
- Bulk insert of media-post relationships with proper ordering
|
||||||
|
|
||||||
|
### Calendar Responsive Improvements
|
||||||
|
|
||||||
|
**Overview**: Enhanced calendar page with mobile-responsive list view, edit functionality for scheduled posts, and improved visual display with page names and platform icons.
|
||||||
|
|
||||||
|
**Desktop View (≥768px)**:
|
||||||
|
- Grid calendar layout with 7 columns (days of week)
|
||||||
|
- Scheduled posts displayed in day cells with:
|
||||||
|
- Time (HH:mm format)
|
||||||
|
- Platform icon (Facebook/Instagram from react-icons/si)
|
||||||
|
- Page name (from social_pages.page_name)
|
||||||
|
- Edit button (blue pencil icon, hover to show)
|
||||||
|
- Delete button (red trash icon, hover to show)
|
||||||
|
- Color coding: Blue for pending posts, Green for published posts
|
||||||
|
- Legend showing status colors
|
||||||
|
|
||||||
|
**Mobile View (<768px)**:
|
||||||
|
- **CalendarListView Component**: Chronological list with date grouping
|
||||||
|
- **Expandable Sections**: Click to expand/collapse posts by date
|
||||||
|
- Each date section displays: "Day Date Month Year" with post count
|
||||||
|
- Highlighted "Aujourd'hui" for current date
|
||||||
|
- Posts display:
|
||||||
|
- Time in large font (text-lg)
|
||||||
|
- Status badge (Programmé/Publié)
|
||||||
|
- Platform icon + page name
|
||||||
|
- Edit button (44px x 44px touch target)
|
||||||
|
- Delete button (44px x 44px touch target)
|
||||||
|
- Responsive breakpoint detection via useMediaQuery hook
|
||||||
|
|
||||||
|
**Edit Scheduled Post Feature**:
|
||||||
|
- **EditScheduledPostDialog Component**: Modal dialog for editing scheduled posts
|
||||||
|
- **Date Picker**: HTML5 date input with YYYY-MM-DD format
|
||||||
|
- **Time Picker**: HTML5 time input with HH:mm format
|
||||||
|
- **Page Selector**: Dropdown with platform icons and page names
|
||||||
|
- Pre-fills current values via useEffect when dialog opens
|
||||||
|
- **API**: PATCH /api/scheduled-posts/:id endpoint
|
||||||
|
- Updates: scheduledAt timestamp and pageId
|
||||||
|
- Cache invalidation after successful update
|
||||||
|
- Toast notification: "Programmation modifiée"
|
||||||
|
|
||||||
|
**Frontend Components Created**:
|
||||||
|
- `client/src/components/calendar-list-view.tsx` - Mobile list view with expandable dates
|
||||||
|
- `client/src/components/edit-scheduled-post-dialog.tsx` - Edit dialog for scheduled posts
|
||||||
|
- `client/src/hooks/use-media-query.ts` - Responsive breakpoint detection hook
|
||||||
|
|
||||||
|
**Backend Changes**:
|
||||||
|
- `PATCH /api/scheduled-posts/:id` - Update scheduledAt and pageId for a scheduled post
|
||||||
|
- Validates date/time and page existence before update
|
||||||
|
- Returns updated scheduled post with joined page data
|
||||||
|
|
||||||
|
**Mobile Optimizations**:
|
||||||
|
- 44px minimum touch targets for all interactive elements
|
||||||
|
- Reduced padding and spacing on mobile devices
|
||||||
|
- Large, readable text sizes for time and status
|
||||||
|
- Expandable sections to reduce visual clutter
|
||||||
|
- Smooth transitions for expand/collapse animations
|
||||||
+117
-1
@@ -9,7 +9,7 @@ import { z } from "zod";
|
|||||||
import { openRouterService } from "./services/openrouter";
|
import { openRouterService } from "./services/openrouter";
|
||||||
import { cloudinaryService } from "./services/cloudinary";
|
import { cloudinaryService } from "./services/cloudinary";
|
||||||
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
|
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
|
||||||
import type { User, InsertUser } from "@shared/schema";
|
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
|
||||||
|
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
@@ -653,6 +653,85 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/posts/:id", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const postWithMedia = await storage.getPostWithMedia(id);
|
||||||
|
if (!postWithMedia) {
|
||||||
|
return res.status(404).json({ error: "Post not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postWithMedia.post.userId !== userId) {
|
||||||
|
return res.status(403).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(postWithMedia);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching post:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch post" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/posts/:id", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
|
const { id } = req.params;
|
||||||
|
const { content } = req.body;
|
||||||
|
|
||||||
|
const post = await storage.getPost(id);
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ error: "Post not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.userId !== userId) {
|
||||||
|
return res.status(403).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPost = await storage.updatePost(id, { content });
|
||||||
|
res.json(updatedPost);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating post:", error);
|
||||||
|
res.status(500).json({ error: "Failed to update post" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/posts/:id/media", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
|
const { id } = req.params;
|
||||||
|
const { mediaIds } = req.body;
|
||||||
|
|
||||||
|
const post = await storage.getPost(id);
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ error: "Post not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.userId !== userId) {
|
||||||
|
return res.status(403).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(mediaIds)) {
|
||||||
|
return res.status(400).json({ error: "mediaIds must be an array" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaIds.length > 10) {
|
||||||
|
return res.status(400).json({ error: "Maximum 10 photos autorisées par publication" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.updatePostMedia(id, mediaIds);
|
||||||
|
const updatedPostWithMedia = await storage.getPostWithMedia(id);
|
||||||
|
res.json(updatedPostWithMedia);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating post media:", error);
|
||||||
|
res.status(500).json({ error: "Failed to update post media" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Scheduled posts
|
// Scheduled posts
|
||||||
app.get("/api/scheduled-posts", requireAuth, async (req, res) => {
|
app.get("/api/scheduled-posts", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -696,6 +775,43 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch("/api/scheduled-posts/:id", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Verify the scheduled post belongs to the user before updating
|
||||||
|
const scheduledPost = await storage.getScheduledPost(id);
|
||||||
|
if (!scheduledPost) {
|
||||||
|
return res.status(404).json({ error: "Scheduled post not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await storage.getPost(scheduledPost.postId);
|
||||||
|
if (!post || post.userId !== userId) {
|
||||||
|
return res.status(403).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow updating scheduledAt and pageId
|
||||||
|
const { scheduledAt, pageId } = req.body;
|
||||||
|
const updateData: Partial<ScheduledPost> = {};
|
||||||
|
|
||||||
|
if (scheduledAt) {
|
||||||
|
updateData.scheduledAt = new Date(scheduledAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageId) {
|
||||||
|
updateData.pageId = pageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await storage.updateScheduledPost(id, updateData);
|
||||||
|
res.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating scheduled post:", error);
|
||||||
|
res.status(500).json({ error: "Failed to update scheduled post" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/scheduled-posts", requireAuth, async (req, res) => {
|
app.post("/api/scheduled-posts", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const scheduledPostData = insertScheduledPostSchema.parse(req.body);
|
const scheduledPostData = insertScheduledPostSchema.parse(req.body);
|
||||||
|
|||||||
@@ -58,9 +58,11 @@ export interface IStorage {
|
|||||||
// Posts
|
// Posts
|
||||||
getPosts(userId: string): Promise<Post[]>;
|
getPosts(userId: string): Promise<Post[]>;
|
||||||
getPost(id: string): Promise<Post | undefined>;
|
getPost(id: string): Promise<Post | undefined>;
|
||||||
|
getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>;
|
||||||
createPost(post: InsertPost): Promise<Post>;
|
createPost(post: InsertPost): Promise<Post>;
|
||||||
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
|
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
|
||||||
deletePost(id: string): Promise<void>;
|
deletePost(id: string): Promise<void>;
|
||||||
|
updatePostMedia(postId: string, mediaIds: string[]): Promise<void>;
|
||||||
|
|
||||||
// Scheduled Posts
|
// Scheduled Posts
|
||||||
getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<ScheduledPost[]>;
|
getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<ScheduledPost[]>;
|
||||||
@@ -181,6 +183,23 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return post || undefined;
|
return post || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined> {
|
||||||
|
const post = await this.getPost(id);
|
||||||
|
if (!post) return undefined;
|
||||||
|
|
||||||
|
const postMediaLinks = await this.getPostMedia(id);
|
||||||
|
const mediaItems: Media[] = [];
|
||||||
|
|
||||||
|
for (const link of postMediaLinks) {
|
||||||
|
const mediaItem = await this.getMediaById(link.mediaId);
|
||||||
|
if (mediaItem) {
|
||||||
|
mediaItems.push(mediaItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { post, media: mediaItems };
|
||||||
|
}
|
||||||
|
|
||||||
async createPost(post: InsertPost): Promise<Post> {
|
async createPost(post: InsertPost): Promise<Post> {
|
||||||
const [newPost] = await db.insert(posts).values(post).returning();
|
const [newPost] = await db.insert(posts).values(post).returning();
|
||||||
return newPost;
|
return newPost;
|
||||||
@@ -195,6 +214,19 @@ export class DatabaseStorage implements IStorage {
|
|||||||
await db.delete(posts).where(eq(posts.id, id));
|
await db.delete(posts).where(eq(posts.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updatePostMedia(postId: string, mediaIds: string[]): Promise<void> {
|
||||||
|
await db.delete(postMedia).where(eq(postMedia.postId, postId));
|
||||||
|
|
||||||
|
if (mediaIds.length > 0) {
|
||||||
|
const postMediaEntries = mediaIds.map((mediaId, index) => ({
|
||||||
|
postId,
|
||||||
|
mediaId,
|
||||||
|
displayOrder: index,
|
||||||
|
}));
|
||||||
|
await db.insert(postMedia).values(postMediaEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Scheduled Posts
|
// Scheduled Posts
|
||||||
async getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<any[]> {
|
async getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<any[]> {
|
||||||
let query = db
|
let query = db
|
||||||
|
|||||||
Reference in new issue
Block a user