mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-21 14:08:10 +02:00
Trois défauts se cumulaient : des réponses bien plus larges que nécessaire, aucun index en base, et des boucles de requêtes là où une seule suffisait. Réponses - /api/scheduled-posts renvoyait la ligne `social_pages` entière, jetons d'accès Facebook et TikTok compris, jusque dans le navigateur. Seules les colonnes réellement affichées sont désormais sélectionnées. - Le filtre de dates est poussé en SQL. Il était appliqué en JavaScript après avoir chargé tout l'historique — que le client ne bornait jamais. Le calendrier demande maintenant les six semaines affichées, le sélecteur de date les créneaux à venir, le bloc « publications récentes » ses 90 jours. - /api/media accepte `limit` et `offset` (sans eux, réponse inchangée : des écrans doivent retrouver un média par son identifiant). La médiathèque pagine côté serveur au lieu de tout charger pour afficher cinq vignettes, et l'écran de création ne demande que les douze médias qu'il propose. Le total voyage dans l'en-tête X-Total-Count pour garder le compteur juste. Index - server/migrate.ts est le seul script exécuté au démarrage et n'en créait aucun ; ceux de migration-docker.sql ne servaient à rien, ce fichier n'étant monté nulle part. Postgres n'indexant pas les clés étrangères, le calendrier, l'historique et le scheduler parcouraient les tables entières. S'y ajoutent deux index partiels pour les boucles de fond : publications en attente et suivi asynchrone TikTok. Requêtes - /api/stats lisait quatre tables entières pour en compter les lignes : c'est désormais quatre COUNT filtrés. - Les vues d'administration listaient les utilisateurs puis interrogeaient les pages de chacun ; une seule requête les remplace. - getPostWithMedia et l'API externe chargeaient les médias un par un. - /api/reels/ongoing chargeait tous les posts de l'utilisateur pour n'en garder que ceux en cours de génération, toutes les trois secondes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
473 lines
21 KiB
TypeScript
473 lines
21 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Trash2, Edit, MoreHorizontal, Eye, Image, Smartphone } from "lucide-react";
|
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
|
import type { ScheduledPost } from "@shared/schema";
|
|
import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
|
|
import EditScheduledPostDialog from "./edit-scheduled-post-dialog";
|
|
import CalendarListView from "./calendar-list-view";
|
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
|
import { PreviewModal } from "@/components/preview-modal";
|
|
import type { Media } from "@shared/schema";
|
|
|
|
/**
|
|
* Bornes de la grille affichée : 42 cases à partir du lundi qui précède le 1er
|
|
* du mois. Demander cette fenêtre plutôt que tout l'historique évite de charger
|
|
* des années de publications pour en afficher six semaines.
|
|
*/
|
|
function visibleRange(currentDate: Date): { startDate: string; endDate: string } {
|
|
const year = currentDate.getFullYear();
|
|
const month = currentDate.getMonth();
|
|
const firstDay = new Date(year, month, 1);
|
|
const startingDayOfWeek = firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1;
|
|
|
|
const start = new Date(year, month, 1 - startingDayOfWeek);
|
|
const end = new Date(year, month, 1 - startingDayOfWeek + 42);
|
|
end.setMilliseconds(-1); // fin de la 42e journée
|
|
|
|
return { startDate: start.toISOString(), endDate: end.toISOString() };
|
|
}
|
|
|
|
export default function CalendarView() {
|
|
const [currentDate, setCurrentDate] = useState(new Date());
|
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
|
const [selectedPost, setSelectedPost] = useState<any>(null);
|
|
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
|
const [previewData, setPreviewData] = useState<{ postText: string; mediaIds: string[]; allMedia: Media[]; generationStatus?: string; generationProgress?: number }>({ postText: '', mediaIds: [], allMedia: [] });
|
|
const { toast } = useToast();
|
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
|
|
// La fenêtre fait partie de la clé : changer de mois redéclenche la requête.
|
|
const range = visibleRange(currentDate);
|
|
|
|
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
|
queryKey: ["/api/scheduled-posts", range],
|
|
refetchInterval: 30000, // Auto-refresh every 30 seconds
|
|
});
|
|
|
|
const deletePostMutation = useMutation({
|
|
mutationFn: (postId: string) =>
|
|
apiRequest('DELETE', `/api/scheduled-posts/${postId}`, {}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'] });
|
|
toast({
|
|
title: "Publication supprimée",
|
|
description: "La publication programmée a été supprimée avec succès",
|
|
});
|
|
},
|
|
onError: () => {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Impossible de supprimer la publication",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const handleDeletePost = (postId: string, e?: React.MouseEvent) => {
|
|
if (e) e.stopPropagation();
|
|
if (confirm("Êtes-vous sûr de vouloir supprimer cette publication programmée ?")) {
|
|
deletePostMutation.mutate(postId);
|
|
}
|
|
};
|
|
|
|
const handleEditPost = (post: any, e?: React.MouseEvent) => {
|
|
if (e) e.stopPropagation();
|
|
setSelectedPost(post);
|
|
setEditDialogOpen(true);
|
|
};
|
|
|
|
const handlePreviewPost = async (scheduledPost: any, e?: React.MouseEvent) => {
|
|
if (e) e.stopPropagation();
|
|
|
|
try {
|
|
const response = await fetch(`/api/posts/${scheduledPost.postId}`, {
|
|
credentials: "include",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to fetch post");
|
|
}
|
|
|
|
const postWithMedia = await response.json();
|
|
const mediaIds = postWithMedia.media?.map((m: Media) => m.id) || [];
|
|
const allMedia = postWithMedia.media || [];
|
|
|
|
setPreviewData({
|
|
postText: postWithMedia.post.content || '',
|
|
mediaIds: mediaIds,
|
|
allMedia: allMedia,
|
|
generationStatus: postWithMedia.post.generationStatus,
|
|
generationProgress: postWithMedia.post.generationProgress,
|
|
});
|
|
|
|
setPreviewModalOpen(true);
|
|
} catch (error) {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Impossible de charger la prévisualisation",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const getPostTypeIcon = (postType: string) => {
|
|
if (postType === 'feed') {
|
|
return <Image className="w-3 h-3" />;
|
|
} else if (postType === 'story') {
|
|
return <Smartphone className="w-3 h-3" />;
|
|
} else if (postType === 'both') {
|
|
return (
|
|
<div className="flex gap-0.5">
|
|
<Image className="w-2.5 h-2.5" />
|
|
<Smartphone className="w-2.5 h-2.5" />
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getPostsForDate = (date: Date) => {
|
|
return (scheduledPosts || []).filter(post => {
|
|
if (!post.scheduledAt) return false;
|
|
const postDate = new Date(post.scheduledAt);
|
|
|
|
// Compare using local date components to avoid timezone issues
|
|
return postDate.getFullYear() === date.getFullYear() &&
|
|
postDate.getMonth() === date.getMonth() &&
|
|
postDate.getDate() === date.getDate();
|
|
});
|
|
};
|
|
|
|
const goToPreviousMonth = () => {
|
|
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1));
|
|
};
|
|
|
|
const goToNextMonth = () => {
|
|
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1));
|
|
};
|
|
|
|
const monthNames = [
|
|
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
|
|
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"
|
|
];
|
|
|
|
const daysOfWeek = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
|
|
|
|
const getDaysInMonth = (date: Date) => {
|
|
const year = date.getFullYear();
|
|
const month = date.getMonth();
|
|
const firstDay = new Date(year, month, 1);
|
|
const lastDay = new Date(year, month + 1, 0);
|
|
const daysInMonth = lastDay.getDate();
|
|
const startingDayOfWeek = firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1;
|
|
|
|
const days = [];
|
|
|
|
for (let i = 0; i < startingDayOfWeek; i++) {
|
|
const prevMonthDay = new Date(year, month, -startingDayOfWeek + i + 1);
|
|
days.push({ date: prevMonthDay, isCurrentMonth: false });
|
|
}
|
|
|
|
for (let i = 1; i <= daysInMonth; i++) {
|
|
days.push({ date: new Date(year, month, i), isCurrentMonth: true });
|
|
}
|
|
|
|
const remainingDays = 42 - days.length;
|
|
for (let i = 1; i <= remainingDays; i++) {
|
|
days.push({ date: new Date(year, month + 1, i), isCurrentMonth: false });
|
|
}
|
|
|
|
return days;
|
|
};
|
|
|
|
const days = getDaysInMonth(currentDate);
|
|
const isToday = (date: Date) => {
|
|
const today = new Date();
|
|
return date.toDateString() === today.toDateString();
|
|
};
|
|
|
|
return (
|
|
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg">
|
|
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg">
|
|
<CalendarIcon className="text-white w-6 h-6" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-foreground">Calendrier</h3>
|
|
<p className="text-sm text-muted-foreground">Visualisez vos publications planifiées</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={goToPreviousMonth}
|
|
className="rounded-xl"
|
|
data-testid="button-prev-month"
|
|
>
|
|
<ChevronLeft className="w-4 h-4 mr-2" />
|
|
Précédent
|
|
</Button>
|
|
<span className="text-sm font-semibold text-foreground min-w-[140px] text-center px-4 py-2 bg-muted/30 rounded-xl">
|
|
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={goToNextMonth}
|
|
className="rounded-xl"
|
|
data-testid="button-next-month"
|
|
>
|
|
Suivant
|
|
<ChevronRight className="w-4 h-4 ml-2" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isDesktop ? (
|
|
<div className="p-8">
|
|
<div className="grid grid-cols-7 gap-2 mb-2">
|
|
{daysOfWeek.map((day) => (
|
|
<div key={day} className="bg-muted/30 py-3 rounded-lg text-center">
|
|
<span className="text-sm font-semibold text-muted-foreground">{day}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 gap-2">
|
|
{days.map((day, index) => (
|
|
<div
|
|
key={index}
|
|
className={`
|
|
bg-card border border-border/50 rounded-xl p-4 min-h-[140px] transition-all hover:shadow-md
|
|
${isToday(day.date) ? "ring-2 ring-primary shadow-lg" : ""}
|
|
${!day.isCurrentMonth ? "opacity-40" : ""}
|
|
`}
|
|
data-testid={`calendar-day-${index}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span
|
|
className={`
|
|
text-sm font-semibold
|
|
${!day.isCurrentMonth ? "text-muted-foreground" : "text-foreground"}
|
|
${isToday(day.date) ? "text-primary" : ""}
|
|
`}
|
|
>
|
|
{day.date.getDate()}
|
|
</span>
|
|
{isToday(day.date) && (
|
|
<span className="text-[10px] bg-gradient-to-r from-primary to-secondary text-white px-2 py-0.5 rounded-full font-semibold">
|
|
Aujourd'hui
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{(() => {
|
|
if (!day.isCurrentMonth) return null;
|
|
const postsForDay = getPostsForDate(day.date);
|
|
if (postsForDay.length === 0) return null;
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{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 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 : platform === 'tiktok' ? SiTiktok : SiFacebook;
|
|
|
|
return (
|
|
<div
|
|
key={idx}
|
|
className={`
|
|
px-3 py-2 rounded-lg text-xs transition-all font-medium shadow-sm group relative
|
|
${isPending ? 'bg-blue-500/20 text-blue-600 hover:bg-blue-500/30' : ''}
|
|
${isPublished ? 'bg-green-500/20 text-green-600 hover:bg-green-500/30' : ''}
|
|
`}
|
|
data-testid={`calendar-post-${post.id}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1 font-semibold">
|
|
{getPostTypeIcon(post.postType)}
|
|
<span>{time}</span>
|
|
</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 className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={(e) => handlePreviewPost(post, e)}
|
|
className={`p-1 rounded ${isPending ? 'hover:bg-blue-500/20' : 'hover:bg-green-500/20'}`}
|
|
data-testid={`button-preview-post-${post.id}`}
|
|
title="Prévisualiser"
|
|
>
|
|
<Eye className={`w-3 h-3 ${isPending ? 'text-blue-500' : 'text-green-500'}`} />
|
|
</button>
|
|
{isPending && (
|
|
<>
|
|
<button
|
|
onClick={(e) => handleEditPost(post, e)}
|
|
className="p-1 hover:bg-blue-500/20 rounded"
|
|
data-testid={`button-edit-post-${post.id}`}
|
|
title="Modifier"
|
|
>
|
|
<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>
|
|
);
|
|
})}
|
|
{postsForDay.length > 2 && (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className="w-full text-left text-[11px] text-primary hover:text-primary/80 pl-2 font-medium transition-colors flex items-center gap-1"
|
|
data-testid="button-show-more-posts"
|
|
>
|
|
<MoreHorizontal className="w-3 h-3" />
|
|
+{postsForDay.length - 2} autre(s)
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-80 p-3" align="start">
|
|
<div className="space-y-2">
|
|
<h4 className="font-semibold text-sm mb-3">
|
|
Tous les posts du {day.date.getDate()} {monthNames[day.date.getMonth()]}
|
|
</h4>
|
|
{postsForDay.slice(2).map((post: any, idx) => {
|
|
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 : platform === 'tiktok' ? SiTiktok : SiFacebook;
|
|
|
|
return (
|
|
<div
|
|
key={idx}
|
|
className={`
|
|
px-3 py-2 rounded-lg text-xs transition-all font-medium shadow-sm group relative
|
|
${isPending ? 'bg-blue-500/20 text-blue-600 hover:bg-blue-500/30' : ''}
|
|
${isPublished ? 'bg-green-500/20 text-green-600 hover:bg-green-500/30' : ''}
|
|
`}
|
|
data-testid={`popover-post-${post.id}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1 font-semibold">
|
|
{getPostTypeIcon(post.postType)}
|
|
<span>{time}</span>
|
|
</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 className="flex gap-1">
|
|
<button
|
|
onClick={(e) => handlePreviewPost(post, e)}
|
|
className={`p-1 rounded ${isPending ? 'hover:bg-blue-500/20' : 'hover:bg-green-500/20'}`}
|
|
data-testid={`button-preview-popover-post-${post.id}`}
|
|
title="Prévisualiser"
|
|
>
|
|
<Eye className={`w-3 h-3 ${isPending ? 'text-blue-500' : 'text-green-500'}`} />
|
|
</button>
|
|
{isPending && (
|
|
<>
|
|
<button
|
|
onClick={(e) => handleEditPost(post, e)}
|
|
className="p-1 hover:bg-blue-500/20 rounded"
|
|
data-testid={`button-edit-popover-post-${post.id}`}
|
|
title="Modifier"
|
|
>
|
|
<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-popover-post-${post.id}`}
|
|
title="Supprimer"
|
|
>
|
|
<Trash2 className="w-3 h-3 text-red-500" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-8 flex items-center justify-center gap-8 flex-wrap">
|
|
<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>
|
|
<span className="text-sm text-muted-foreground font-medium">Programmé</span>
|
|
</div>
|
|
<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>
|
|
<span className="text-sm text-muted-foreground font-medium">Publié</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<CalendarListView
|
|
scheduledPosts={scheduledPosts}
|
|
onEditPost={handleEditPost}
|
|
onDeletePost={handleDeletePost}
|
|
onPreviewPost={handlePreviewPost}
|
|
/>
|
|
)}
|
|
|
|
<EditScheduledPostDialog
|
|
open={editDialogOpen}
|
|
onOpenChange={setEditDialogOpen}
|
|
scheduledPost={selectedPost || {}}
|
|
/>
|
|
|
|
<PreviewModal
|
|
open={previewModalOpen}
|
|
onOpenChange={setPreviewModalOpen}
|
|
postText={previewData.postText}
|
|
selectedMedia={previewData.mediaIds}
|
|
mediaList={previewData.allMedia}
|
|
onPublish={() => { }}
|
|
isPublishing={false}
|
|
readOnly={true}
|
|
generationStatus={previewData.generationStatus}
|
|
generationProgress={previewData.generationProgress}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|