mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 06:58:12 +02:00
Ajoute la gestion multi-comptes TikTok et permet d'envoyer la même vidéo sur plusieurs pages Facebook ET plusieurs comptes TikTok en une seule publication. Un compte TikTok vit dans social_pages comme une page Facebook : il hérite donc des permissions par utilisateur, de la planification et du calendrier. Trois particularités de l'API TikTok structurent l'implémentation : - pas de jeton saisi à la main : chaque compte passe par OAuth, et l'access token (24h) est renouvelé via le refresh token (1 an) avant chaque publication ; - la publication est asynchrone : l'upload rend un publish_id, et un poller récupère l'identifiant définitif du post ; - creator_info doit être interrogé avant chaque envoi pour ne demander qu'un niveau de confidentialité réellement autorisé sur le compte. Serveur : - service TikTok (OAuth, creator_info, upload FILE_UPLOAD par chunks, suivi de statut) et routes de connexion/configuration - schéma : platform 'tiktok', refresh token et scopes sur social_pages, publish_id/publish_status sur scheduled_posts, table tiktok_config - routage par plateforme dans les deux flux de reels et le planificateur - cron de suivi des publications, token manager et analytics adaptés Client : - connexion et reconnexion des comptes TikTok depuis « Pages gérées » - sélection combinée pages Facebook / comptes TikTok à la publication - configuration de l'application TikTok dans les paramètres (admin) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
241 lines
9.9 KiB
TypeScript
241 lines
9.9 KiB
TypeScript
import { format, isSameDay, parseISO } from "date-fns";
|
|
import { fr } from "date-fns/locale";
|
|
import { Edit, Trash2, ChevronDown, ChevronUp, Eye, Image, Smartphone } from "lucide-react";
|
|
import { SiFacebook, SiInstagram, SiTiktok } 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;
|
|
onPreviewPost: (post: any) => void;
|
|
}
|
|
|
|
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;
|
|
const date = format(parseISO(post.scheduledAt), "yyyy-MM-dd");
|
|
if (!acc[date]) {
|
|
acc[date] = [];
|
|
}
|
|
acc[date].push(post);
|
|
return acc;
|
|
}, {});
|
|
|
|
// Sort dates - put today first, then future dates in ascending order, then past dates
|
|
const today = format(new Date(), "yyyy-MM-dd");
|
|
const sortedDates = Object.keys(groupedPosts).sort((a, b) => {
|
|
// Today always comes first
|
|
if (a === today && b !== today) return -1;
|
|
if (b === today && a !== today) return 1;
|
|
|
|
// Both are not today - separate future and past dates
|
|
const aIsFuture = a > today;
|
|
const bIsFuture = b > today;
|
|
|
|
// Both are future: ascending chronological order (soonest first)
|
|
if (aIsFuture && bIsFuture) {
|
|
return a.localeCompare(b);
|
|
}
|
|
|
|
// Both are past: ascending chronological order (most recent past first)
|
|
if (!aIsFuture && !bIsFuture) {
|
|
return b.localeCompare(a);
|
|
}
|
|
|
|
// One is future, one is past: future comes first
|
|
return aIsFuture ? -1 : 1;
|
|
});
|
|
|
|
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, index) => {
|
|
const posts = groupedPosts[dateStr];
|
|
const isExpanded = expandedDates.has(dateStr);
|
|
const dateObj = parseISO(dateStr);
|
|
const isDateToday = isToday(dateStr);
|
|
|
|
// Check if we need to show the separator (first past date)
|
|
const isPastDate = dateStr < today;
|
|
const prevDate = index > 0 ? sortedDates[index - 1] : null;
|
|
const prevWasFutureOrToday = prevDate ? (prevDate >= today) : false;
|
|
const showSeparator = isPastDate && prevWasFutureOrToday;
|
|
|
|
return (
|
|
<div key={`wrapper-${dateStr}`}>
|
|
{showSeparator && (
|
|
<div
|
|
key="past-separator"
|
|
className="flex items-center gap-3 py-2"
|
|
data-testid="separator-past-dates"
|
|
>
|
|
<div className="flex-1 h-px bg-muted-foreground/30"></div>
|
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
|
Publications passées
|
|
</span>
|
|
<div className="flex-1 h-px bg-muted-foreground/30"></div>
|
|
</div>
|
|
)}
|
|
<div
|
|
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 : platform === 'tiktok' ? SiTiktok : 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">
|
|
{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
|
|
? '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>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onPreviewPost(post)}
|
|
className="h-11 w-11 p-0"
|
|
data-testid={`button-preview-post-${post.id}`}
|
|
title="Prévisualiser"
|
|
>
|
|
<Eye className={`w-5 h-5 ${isPending ? 'text-blue-500' : 'text-green-500'}`} />
|
|
</Button>
|
|
{isPending && (
|
|
<>
|
|
<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>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
);
|
|
}
|