mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
5 files changed
+232
-61
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { format, isSameDay, parseISO } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { Edit, Trash2, ChevronDown, ChevronUp, Eye } from "lucide-react";
|
||||
import { Edit, Trash2, ChevronDown, ChevronUp, Eye, Image, Smartphone } from "lucide-react";
|
||||
import { SiFacebook, SiInstagram } from "react-icons/si";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
@@ -15,6 +15,22 @@ interface CalendarListViewProps {
|
||||
export default function CalendarListView({ scheduledPosts, onEditPost, onDeletePost, onPreviewPost }: CalendarListViewProps) {
|
||||
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
|
||||
|
||||
const getPostTypeIcon = (postType: string) => {
|
||||
if (postType === 'feed') {
|
||||
return <Image className="w-4 h-4" />;
|
||||
} else if (postType === 'story') {
|
||||
return <Smartphone className="w-4 h-4" />;
|
||||
} else if (postType === 'both') {
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
<Image className="w-3 h-3" />
|
||||
<Smartphone className="w-3 h-3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Group posts by date
|
||||
const groupedPosts = scheduledPosts.reduce((acc: { [key: string]: any[] }, post) => {
|
||||
if (!post.scheduledAt) return acc;
|
||||
@@ -115,6 +131,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
|
||||
<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">
|
||||
{getPostTypeIcon(post.postType)}
|
||||
<span className="font-semibold text-lg text-foreground">{time}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
isPending
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
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 } from "lucide-react";
|
||||
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";
|
||||
@@ -24,6 +24,7 @@ export default function CalendarView() {
|
||||
|
||||
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
||||
queryKey: ["/api/scheduled-posts"],
|
||||
refetchInterval: 30000, // Auto-refresh every 30 seconds
|
||||
});
|
||||
|
||||
const deletePostMutation = useMutation({
|
||||
@@ -90,6 +91,22 @@ export default function CalendarView() {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -256,7 +273,10 @@ export default function CalendarView() {
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold">{time}</div>
|
||||
<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>
|
||||
@@ -332,7 +352,10 @@ export default function CalendarView() {
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold">{time}</div>
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Facebook, Instagram, Clock, CheckCircle2, XCircle, Eye } from "lucide-react";
|
||||
import { Facebook, Instagram, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema";
|
||||
@@ -60,6 +60,22 @@ export default function RecentPublications() {
|
||||
}
|
||||
};
|
||||
|
||||
const getPostTypeIcon = (postType: string) => {
|
||||
if (postType === 'feed') {
|
||||
return <ImageIcon className="w-3.5 h-3.5" />;
|
||||
} else if (postType === 'story') {
|
||||
return <Smartphone className="w-3.5 h-3.5" />;
|
||||
} else if (postType === 'both') {
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
<ImageIcon className="w-3 h-3" />
|
||||
<Smartphone className="w-3 h-3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg" data-testid="card-recent-publications">
|
||||
<CardHeader>
|
||||
@@ -136,9 +152,10 @@ export default function RecentPublications() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||
<span className="capitalize">
|
||||
{scheduledPost.postType === 'feed' ? 'Publication' :
|
||||
scheduledPost.postType === 'story' ? 'Story' : scheduledPost.postType}
|
||||
<span className="flex items-center gap-1 capitalize">
|
||||
{getPostTypeIcon(scheduledPost.postType)}
|
||||
{scheduledPost.postType === 'feed' ? 'Feed' :
|
||||
scheduledPost.postType === 'story' ? 'Story' : 'Feed & Story'}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
|
||||
+163
-52
@@ -1,22 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Clock, CheckCircle, XCircle, Calendar, History as HistoryIcon } from "lucide-react";
|
||||
import { Clock, CheckCircle, XCircle, Calendar, History as HistoryIcon, Eye, Image as ImageIcon, Smartphone } from "lucide-react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Post } from "@shared/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { PreviewModal } from "@/components/preview-modal";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
type ScheduledPostWithRelations = ScheduledPost & {
|
||||
post?: Post;
|
||||
page?: SocialPage;
|
||||
};
|
||||
|
||||
export default function History() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||
const [previewData, setPreviewData] = useState<{ postText: string; mediaIds: string[]; allMedia: Media[] }>({ postText: '', mediaIds: [], allMedia: [] });
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: posts = [], isLoading } = useQuery<Post[]>({
|
||||
queryKey: ['/api/posts'],
|
||||
const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({
|
||||
queryKey: ['/api/scheduled-posts'],
|
||||
});
|
||||
|
||||
const publishedPosts = posts.filter(p => p.status === 'published');
|
||||
// Filter attempted posts (scheduled in the past)
|
||||
const now = new Date();
|
||||
const publishedPosts = scheduledPosts
|
||||
.filter(sp => sp.scheduledAt && new Date(sp.scheduledAt) <= now)
|
||||
.sort((a, b) => new Date(b.scheduledAt!).getTime() - new Date(a.scheduledAt!).getTime());
|
||||
|
||||
const handlePreviewPost = async (scheduledPost: ScheduledPostWithRelations) => {
|
||||
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,
|
||||
});
|
||||
|
||||
setPreviewModalOpen(true);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de charger la prévisualisation",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getPostTypeIcon = (postType: string) => {
|
||||
if (postType === 'feed') {
|
||||
return <ImageIcon className="w-4 h-4" />;
|
||||
} else if (postType === 'story') {
|
||||
return <Smartphone className="w-4 h-4" />;
|
||||
} else if (postType === 'both') {
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
<ImageIcon className="w-3 h-3" />
|
||||
<Smartphone className="w-3 h-3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
@@ -65,60 +126,110 @@ export default function History() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{publishedPosts.map((post) => (
|
||||
<Card key={post.id} className="rounded-2xl border-border/50 shadow-lg overflow-hidden" data-testid={`card-post-${post.id}`}>
|
||||
<CardHeader className="border-b border-border/50 bg-gradient-to-r from-muted/20 to-muted/10 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-success to-success/80 flex items-center justify-center shadow-md">
|
||||
<CheckCircle className="w-5 h-5 text-white" />
|
||||
{publishedPosts.map((scheduledPost) => {
|
||||
const isPending = !scheduledPost.publishedAt;
|
||||
const hasError = !!scheduledPost.error;
|
||||
|
||||
return (
|
||||
<Card key={scheduledPost.id} className="rounded-2xl border-border/50 shadow-lg overflow-hidden" data-testid={`card-post-${scheduledPost.id}`}>
|
||||
<CardHeader className="border-b border-border/50 bg-gradient-to-r from-muted/20 to-muted/10 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shadow-md ${
|
||||
hasError ? 'bg-gradient-to-br from-destructive to-destructive/80' :
|
||||
'bg-gradient-to-br from-success to-success/80'
|
||||
}`}>
|
||||
{hasError ? (
|
||||
<XCircle className="w-5 h-5 text-white" />
|
||||
) : (
|
||||
<CheckCircle className="w-5 h-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-lg">
|
||||
{scheduledPost.page?.pageName || 'Page inconnue'}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardTitle className="text-lg">
|
||||
Publication #{post.id.substring(0, 8)}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-foreground leading-relaxed line-clamp-3">
|
||||
{scheduledPost.post?.content || 'Aucun texte'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePreviewPost(scheduledPost)}
|
||||
className="h-9 w-9 p-0"
|
||||
data-testid={`button-preview-post-${scheduledPost.id}`}
|
||||
title="Prévisualiser"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Badge
|
||||
className={`${
|
||||
hasError
|
||||
? 'bg-destructive/20 text-destructive hover:bg-destructive/30'
|
||||
: 'bg-success/20 text-success hover:bg-success/30'
|
||||
}`}
|
||||
>
|
||||
{hasError ? (
|
||||
<>
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Échoué
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Publié
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-foreground leading-relaxed line-clamp-3">
|
||||
{post.content}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
className={`ml-4 ${
|
||||
post.status === 'published'
|
||||
? 'bg-success/20 text-success hover:bg-success/30'
|
||||
: 'bg-destructive/20 text-destructive hover:bg-destructive/30'
|
||||
}`}
|
||||
>
|
||||
{post.status === 'published' && <CheckCircle className="w-3 h-3 mr-1" />}
|
||||
{post.status === 'failed' && <XCircle className="w-3 h-3 mr-1" />}
|
||||
{post.status === 'published' ? 'Publié' : 'Échoué'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-6 text-sm text-muted-foreground flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span className="font-medium">
|
||||
{post.updatedAt
|
||||
? format(new Date(post.updatedAt), "d MMM yyyy 'à' HH:mm", { locale: fr })
|
||||
: 'Date inconnue'
|
||||
}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-6 text-sm text-muted-foreground flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{getPostTypeIcon(scheduledPost.postType)}
|
||||
<span className="font-medium capitalize">
|
||||
{scheduledPost.postType === 'feed' ? 'Feed' :
|
||||
scheduledPost.postType === 'story' ? 'Story' : 'Feed & Story'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span className="font-medium">
|
||||
{scheduledPost.scheduledAt
|
||||
? format(new Date(scheduledPost.scheduledAt), "d MMM yyyy 'à' HH:mm", { locale: fr })
|
||||
: 'Date inconnue'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
{scheduledPost.post?.aiGenerated && (
|
||||
<Badge variant="outline" className="text-xs bg-gradient-to-r from-primary/5 to-secondary/5 border-primary/20">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Généré par IA
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{post.aiGenerated && (
|
||||
<Badge variant="outline" className="text-xs bg-gradient-to-r from-primary/5 to-secondary/5 border-primary/20">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Généré par IA
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PreviewModal
|
||||
open={previewModalOpen}
|
||||
onOpenChange={setPreviewModalOpen}
|
||||
postText={previewData.postText}
|
||||
selectedMedia={previewData.mediaIds}
|
||||
mediaList={previewData.allMedia}
|
||||
onPublish={() => {}}
|
||||
isPublishing={false}
|
||||
readOnly={true}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,9 @@ Preferred communication style: Simple, everyday language.
|
||||
## Recent Changes
|
||||
|
||||
### October 10, 2025
|
||||
- **Post Type Visual Indicators**: Added visual icons throughout the app to distinguish post types at a glance. Feed posts display Image icon (🖼️), Story posts display Smartphone icon (📱), and posts configured for both show both icons side-by-side. Icons appear consistently across calendar view (desktop grid and mobile list), dashboard "Historique des publications" section, and history page for improved visual clarity.
|
||||
- **Calendar Auto-Refresh**: Implemented automatic calendar data refresh every 30 seconds using TanStack Query's `refetchInterval` option. Calendar continuously fetches updated scheduled posts without requiring page reload or manual refresh, ensuring users see the latest schedule data in real-time.
|
||||
- **History Preview Access Control**: Fixed GET /api/posts/:id permissions to use page-based access instead of ownership check. Users can now preview any post from their assigned pages in the history section, not just their own posts. Admin users retain full preview access to all posts.
|
||||
- **Calendar Permissions System**: Implemented comprehensive page-based permissions for calendar access. Admin users see all scheduled posts with full edit/delete rights. Standard users see only posts from their assigned pages via `user_page_permissions` table, but can only edit/delete their own posts (ownership check via `post.userId`). GET /api/scheduled-posts filters posts by accessible pages (admin bypass). DELETE and PATCH /api/scheduled-posts enforce ownership validation (admin can modify any post, users limited to own posts).
|
||||
- **Scheduled Posts Data Sync Fix**: Fixed critical bug where editing scheduled post time via PATCH /api/scheduled-posts/:id updated `scheduled_posts.scheduledAt` but not `posts.scheduledFor`, causing calendar/database inconsistency. Now both tables are synchronized: updating scheduledAt automatically updates scheduledFor via `storage.updatePost()`.
|
||||
- **Scheduled Posts Validation**: Added validation to POST /api/posts requiring `pageIds` when `scheduledFor` is provided. Prevents orphaned scheduled posts that would be invisible in calendar. Returns 400 error: "Les posts programmés nécessitent au moins une page cible".
|
||||
|
||||
Reference in new issue
Block a user