Refonte UI : SQL, musiques, historique, comptes connectés, utilisateurs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ADkrVcVFTT14hbB148HYS
This commit is contained in:
Claude committed 2026-10-06 12:47:49 +00:00
1 parent 155c3385c1
commit 7c18dc1e16
11 files changed
+745 -973

No files matched your search

+18 -14
View File
@@ -19,10 +19,10 @@ import { fr } from "date-fns/locale";
type TokenStatus = 'valid' | 'expiring' | 'expired' | 'error';
const STATUS_STYLES: Record<TokenStatus, { label: string; color: string; bgColor: string }> = {
valid: { label: 'Jeton valide', color: 'text-green-600', bgColor: 'bg-green-100' },
expiring: { label: 'Jeton bientôt expiré', color: 'text-orange-600', bgColor: 'bg-orange-100' },
expired: { label: 'Jeton expiré', color: 'text-red-600', bgColor: 'bg-red-100' },
error: { label: 'Contrôle en échec', color: 'text-red-600', bgColor: 'bg-red-100' },
valid: { label: 'Connexion active', color: 'text-success', bgColor: 'bg-success/10' },
expiring: { label: 'Expire bientôt', color: 'text-warning-foreground dark:text-warning', bgColor: 'bg-warning/15' },
expired: { label: 'Connexion expirée', color: 'text-destructive', bgColor: 'bg-destructive/10' },
error: { label: 'Contrôle en échec', color: 'text-destructive', bgColor: 'bg-destructive/10' },
};
function statusOf(page: ClientSocialPage): TokenStatus {
@@ -33,14 +33,14 @@ function statusOf(page: ClientSocialPage): TokenStatus {
* Lance l'autorisation Facebook. La connexion se fait par navigation complète
* du navigateur (et non en fetch) puisqu'elle passe par le site de Facebook.
*/
export function ConnectFacebookButton({ className }: { className?: string }) {
export function useConnectFacebook() {
const { toast } = useToast();
const { data: config } = useQuery<{ configured: boolean }>({
queryKey: ['/api/facebook/config'],
});
const handleClick = () => {
return () => {
if (config && !config.configured) {
toast({
title: "Facebook n'est pas configuré",
@@ -52,6 +52,10 @@ export function ConnectFacebookButton({ className }: { className?: string }) {
}
window.location.href = '/api/facebook/connect';
};
}
export function ConnectFacebookButton({ className }: { className?: string }) {
const handleClick = useConnectFacebook();
return (
<Button
@@ -60,7 +64,7 @@ export function ConnectFacebookButton({ className }: { className?: string }) {
onClick={handleClick}
data-testid="button-connect-facebook"
>
<Facebook className="w-4 h-4 mr-2" />
<Facebook className="w-4 h-4" />
Connecter des pages Facebook
</Button>
);
@@ -108,8 +112,8 @@ export function RefreshTokenButton({ page }: { page: ClientSocialPage }) {
disabled={refreshMutation.isPending}
data-testid={`button-refresh-token-${page.id}`}
>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
{refreshMutation.isPending ? 'Contrôle…' : 'Vérifier le jeton'}
<RefreshCw className={`w-4 h-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
{refreshMutation.isPending ? 'Vérification…' : 'Vérifier'}
</Button>
);
}
@@ -130,9 +134,9 @@ export function TokenHealthPanel({ page }: { page: ClientSocialPage }) {
<div className={`text-xs px-3 py-2 rounded-lg space-y-1 ${style.bgColor}`}>
<div className="flex items-center gap-2">
{status === 'valid' ? (
<CheckCircle2 className="w-4 h-4 text-green-600 shrink-0" />
<CheckCircle2 className={`w-4 h-4 shrink-0 ${style.color}`} />
) : (
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0" />
<AlertTriangle className={`w-4 h-4 shrink-0 ${style.color}`} />
)}
<span className={`font-semibold ${style.color}`}>{style.label}</span>
</div>
@@ -183,10 +187,10 @@ export function TokenAlertBanner({ pages }: { pages: ClientSocialPage[] }) {
if (failing.length === 0) return null;
return (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl flex gap-3">
<AlertTriangle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
<div className="mb-6 p-4 bg-destructive/10 border border-destructive/30 rounded-xl flex gap-3">
<AlertTriangle className="w-5 h-5 text-destructive shrink-0 mt-0.5" />
<div className="text-sm space-y-1">
<p className="font-semibold text-red-600 dark:text-red-400">
<p className="font-semibold text-destructive">
{failing.length === 1
? 'Une page nécessite votre attention'
: `${failing.length} pages nécessitent votre attention`}
+121 -136
View File
@@ -1,8 +1,18 @@
import { useState } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { EmptyState } from "@/components/empty-state";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { Trash2, Music, Upload, Loader2, Play, Pause } from "lucide-react";
@@ -20,10 +30,10 @@ interface AudioTrack {
}
export default function AudioAdmin() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [playingTrackId, setPlayingTrackId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
const [trackToDelete, setTrackToDelete] = useState<AudioTrack | null>(null);
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -149,140 +159,115 @@ export default function AudioAdmin() {
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
<Page width="default">
<input
type="file"
id="audio-upload"
className="hidden"
accept="audio/*"
multiple
onChange={handleFileUpload}
disabled={isUploading}
/>
<PageHeader
icon={Music}
title="Musiques"
description="Les pistes proposées lors de la création d'un Reel."
actions={
<Button asChild disabled={isUploading} className="cursor-pointer">
<label htmlFor="audio-upload">
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{isUploading ? "Import en cours…" : "Importer des musiques"}
</label>
</Button>
}
/>
{isLoading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<div key={i} className="skeleton h-[76px] rounded-xl" />
))}
</div>
) : tracks.length === 0 ? (
<EmptyState
icon={Music}
title="Aucune musique"
description="Importez des fichiers audio (MP3, WAV…) pour les proposer dans vos Reels."
action={
<Button asChild className="cursor-pointer">
<label htmlFor="audio-upload">
<Upload className="h-4 w-4" /> Importer des musiques
</label>
</Button>
}
/>
) : (
<>
<p className="mb-3 text-sm text-muted-foreground">{tracks.length} piste(s)</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{tracks.map((track) => {
const playing = playingTrackId === track.id;
return (
<div
key={track.id}
className={`flex items-center gap-3 rounded-xl border bg-card p-3 shadow-soft transition-colors ${playing ? "border-primary/50" : ""}`}
data-testid={`track-${track.id}`}
>
<Button
variant={playing ? "default" : "secondary"}
size="icon"
className="h-11 w-11 shrink-0 rounded-full"
onClick={() => togglePlay(track)}
aria-label={playing ? "Pause" : "Écouter"}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium" title={track.title}>{track.title}</p>
<p className="text-xs text-muted-foreground">
Ajoutée le {format(new Date(track.createdAt), "d MMM yyyy", { locale: fr })}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={() => setTrackToDelete(track)}
disabled={deleteMutation.isPending}
aria-label="Supprimer"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
</>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto w-full">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-7xl mx-auto space-y-8">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<Music className="w-8 h-8 text-primary" />
Bibliothèque Audio
</h1>
<p className="text-muted-foreground mt-2">
Gérez les musiques disponibles pour la création de Reels
</p>
</div>
<div>
<input
type="file"
id="audio-upload"
className="hidden"
accept="audio/*"
multiple
onChange={handleFileUpload}
disabled={isUploading}
/>
<label htmlFor="audio-upload">
<Button
asChild
disabled={isUploading}
className="cursor-pointer"
>
<span>
{isUploading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{isUploading ? "Upload en cours..." : "Ajouter un fichier audio"}
</span>
</Button>
</label>
</div>
</div>
<Card className="border-border/50 shadow-sm">
<CardHeader>
<CardTitle>Musiques ({tracks.length})</CardTitle>
<CardDescription>
Ces pistes pourront être sélectionnées par les utilisateurs lors de la création d'un nouveau Reel.
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center p-8">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : tracks.length === 0 ? (
<div className="text-center p-12 border-2 border-dashed border-border rounded-xl">
<Music className="w-12 h-12 text-muted-foreground mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-medium mb-1">Aucune musique</h3>
<p className="text-muted-foreground">
Vous n'avez pas encore ajouté de piste audio. Les musiques ajoutées ici seront utilisables pour les Reels.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tracks.map((track) => (
<div
key={track.id}
className="bg-card border border-border/50 rounded-xl p-4 flex flex-col gap-4 hover:border-primary/30 transition-colors shadow-sm"
>
<div className="flex items-start justify-between gap-3">
<div className="p-3 bg-primary/10 rounded-lg shrink-0">
<Music className="w-6 h-6 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm truncate" title={track.title}>{track.title}</p>
<p className="text-xs text-muted-foreground mt-1">
{format(new Date(track.createdAt), "dd MMM yyyy", { locale: fr })}
</p>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-border/50">
<Button
variant="ghost"
size="sm"
className={playingTrackId === track.id ? "text-primary bg-primary/10" : ""}
onClick={() => togglePlay(track)}
>
{playingTrackId === track.id ? (
<><Pause className="w-4 h-4 mr-2" /> Pause</>
) : (
<><Play className="w-4 h-4 mr-2" /> Écouter</>
)}
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (confirm("Voulez-vous vraiment supprimer cette musique ?")) {
deleteMutation.mutate(track.id);
}
}}
disabled={deleteMutation.isPending}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</main>
</div>
<AlertDialog open={!!trackToDelete} onOpenChange={(open) => !open && setTrackToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer cette musique ?</AlertDialogTitle>
<AlertDialogDescription>
« {trackToDelete?.title} » ne sera plus proposée dans les nouveaux Reels.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (trackToDelete) deleteMutation.mutate(trackToDelete.id);
setTrackToDelete(null);
}}
>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Page>
);
}
+163 -151
View File
@@ -1,9 +1,14 @@
import { useState } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery } from "@tanstack/react-query";
import { Clock, CheckCircle, XCircle, Calendar, History as HistoryIcon, Eye, Image as ImageIcon, Smartphone, Loader2, Clapperboard } from "lucide-react";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CheckCircle, XCircle, Calendar, History as HistoryIcon, Eye, Image as ImageIcon, Smartphone, Loader2, Clapperboard, Search, Sparkles } from "lucide-react";
import { Link } from "wouter";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EmptyState } from "@/components/empty-state";
import { PlatformIcon } from "@/components/platform-icon";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema";
@@ -26,8 +31,21 @@ function isAwaitingPublication(scheduledPost: ScheduledPostWithRelations): boole
);
}
type StatusFilter = "all" | "published" | "pending" | "failed";
const STATUS_TABS: { value: StatusFilter; label: string }[] = [
{ value: "all", label: "Toutes" },
{ value: "published", label: "Publiées" },
{ value: "pending", label: "En cours" },
{ value: "failed", label: "Échecs" },
];
const PAGE_SIZE = 20;
export default function History() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
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();
@@ -81,11 +99,11 @@ export default function History() {
const getPostTypeIcon = (postType: string) => {
if (postType === 'feed') {
return <ImageIcon className="w-4 h-4" />;
return <ImageIcon className="h-3.5 w-3.5" />;
} else if (postType === 'story') {
return <Smartphone className="w-4 h-4" />;
return <Smartphone className="h-3.5 w-3.5" />;
} else if (postType === 'reel') {
return <Clapperboard className="w-4 h-4" />;
return <Clapperboard className="h-3.5 w-3.5" />;
} else if (postType === 'both') {
return (
<div className="flex gap-0.5">
@@ -97,155 +115,150 @@ export default function History() {
return null;
};
const statusOf = (sp: ScheduledPostWithRelations): StatusFilter =>
sp.error ? "failed" : !sp.publishedAt ? "pending" : "published";
const counts = publishedPosts.reduce(
(acc, sp) => {
acc[statusOf(sp)] += 1;
return acc;
},
{ all: publishedPosts.length, published: 0, pending: 0, failed: 0 } as Record<StatusFilter, number>,
);
const term = searchTerm.trim().toLowerCase();
const filteredPosts = publishedPosts.filter(
(sp) =>
(statusFilter === "all" || statusOf(sp) === statusFilter) &&
(!term ||
sp.post?.content?.toLowerCase().includes(term) ||
sp.page?.pageName?.toLowerCase().includes(term)),
);
const visiblePosts = filteredPosts.slice(0, visibleCount);
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
<>
<Page width="default">
<PageHeader
icon={HistoryIcon}
title="Historique"
description="Toutes les publications envoyées et leur résultat."
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-5xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Historique</h1>
<p className="text-muted-foreground mt-2">
Consultez l'historique de vos publications
</p>
</div>
{isLoading ? (
<div className="space-y-6">
{[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-card rounded-2xl border border-border/50 animate-pulse" />
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Tabs value={statusFilter} onValueChange={(v) => { setStatusFilter(v as StatusFilter); setVisibleCount(PAGE_SIZE); }}>
<TabsList className="w-full justify-start overflow-x-auto scrollbar-none sm:w-auto">
{STATUS_TABS.map((t) => (
<TabsTrigger key={t.value} value={t.value} className="gap-1.5" data-testid={`tab-history-${t.value}`}>
{t.label}
<span className="rounded-full bg-muted-foreground/10 px-1.5 text-[11px] tabular-nums">{counts[t.value]}</span>
</TabsTrigger>
))}
</div>
) : publishedPosts.length === 0 ? (
<Card className="rounded-2xl border-border/50 border-dashed shadow-lg">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="w-20 h-20 rounded-2xl bg-muted/30 flex items-center justify-center mb-6">
<HistoryIcon className="w-10 h-10 text-muted-foreground opacity-50" />
</div>
<h3 className="text-xl font-semibold mb-2">Aucune publication</h3>
<p className="text-muted-foreground text-center max-w-md">
Vos publications apparaîtront ici une fois publiées
</p>
</CardContent>
</Card>
) : (
<div className="space-y-6">
{publishedPosts.map((scheduledPost) => {
const isPending = !scheduledPost.publishedAt;
const hasError = !!scheduledPost.error;
</TabsList>
</Tabs>
<div className="relative sm:w-72">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Rechercher un texte ou une page…"
value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); setVisibleCount(PAGE_SIZE); }}
className="pl-9"
data-testid="input-history-search"
/>
</div>
</div>
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' :
isPending ? 'bg-gradient-to-br from-blue-500 to-blue-600' :
'bg-gradient-to-br from-green-500 to-green-600'
}`}>
{hasError ? (
<XCircle className="w-5 h-5 text-white" />
) : isPending ? (
<Loader2 className="w-5 h-5 text-white animate-spin" />
) : (
<CheckCircle className="w-5 h-5 text-white fill-white" />
)}
</div>
<CardTitle className="text-lg">
{scheduledPost.page?.pageName || 'Page inconnue'}
</CardTitle>
</div>
<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'
: isPending
? 'bg-blue-500/20 text-blue-500 hover:bg-blue-500/30'
: 'bg-success/20 text-success hover:bg-success/30'
}`}
>
{hasError ? (
<>
<XCircle className="w-3 h-3 mr-1" />
Échoué
</>
) : isPending ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
Traitement...
</>
) : (
<>
<CheckCircle className="w-3 h-3 mr-1" />
Publié
</>
)}
</Badge>
</div>
</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">
{getPostTypeIcon(scheduledPost.postType)}
<span className="font-medium capitalize">
{scheduledPost.postType === 'feed' ? 'Feed' :
scheduledPost.postType === 'story' ? 'Story' :
scheduledPost.postType === 'reel' ? 'Reel' : '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>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="skeleton h-24 rounded-xl" />
))}
</div>
) : publishedPosts.length === 0 ? (
<EmptyState
icon={HistoryIcon}
title="Aucune publication"
description="Vos publications apparaîtront ici une fois envoyées."
action={
<Button asChild>
<Link href="/new">Créer une publication</Link>
</Button>
}
/>
) : filteredPosts.length === 0 ? (
<EmptyState icon={Search} title="Aucun résultat" description="Essayez un autre mot-clé ou un autre filtre." />
) : (
<div className="space-y-3">
{visiblePosts.map((scheduledPost) => {
const status = statusOf(scheduledPost);
return (
<Card key={scheduledPost.id} className="p-4" data-testid={`card-post-${scheduledPost.id}`}>
<div className="flex items-start gap-3">
<PlatformIcon platform={scheduledPost.page?.platform ?? "facebook"} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate font-medium">{scheduledPost.page?.pageName || "Page inconnue"}</p>
{status === "failed" ? (
<Badge variant="danger" className="gap-1"><XCircle className="h-3 w-3" /> Échec</Badge>
) : status === "pending" ? (
<Badge variant="info" className="gap-1"><Loader2 className="h-3 w-3 animate-spin" /> En cours</Badge>
) : (
<Badge variant="success" className="gap-1"><CheckCircle className="h-3 w-3" /> Publié</Badge>
)}
{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>
<Badge variant="muted" className="gap-1"><Sparkles className="h-3 w-3" /> IA</Badge>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{scheduledPost.post?.content || <span className="italic">Aucun texte</span>}
</p>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
{getPostTypeIcon(scheduledPost.postType)}
{scheduledPost.postType === "feed"
? "Feed"
: scheduledPost.postType === "story"
? "Story"
: scheduledPost.postType === "reel"
? "Reel"
: "Feed & Story"}
</span>
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
{scheduledPost.scheduledAt
? format(new Date(scheduledPost.scheduledAt), "d MMM yyyy 'à' HH:mm", { locale: fr })
: "Date inconnue"}
</span>
</div>
{scheduledPost.error && (
<p className="mt-2 rounded-md bg-destructive/10 px-2.5 py-1.5 text-xs text-destructive">{scheduledPost.error}</p>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handlePreviewPost(scheduledPost)}
className="shrink-0 text-muted-foreground"
data-testid={`button-preview-post-${scheduledPost.id}`}
title="Prévisualiser"
aria-label="Prévisualiser"
>
<Eye className="h-4 w-4" />
</Button>
</div>
</Card>
);
})}
{filteredPosts.length > visibleCount && (
<div className="flex justify-center pt-2">
<Button variant="outline" onClick={() => setVisibleCount((c) => c + PAGE_SIZE)} data-testid="button-history-more">
Afficher plus ({filteredPosts.length - visibleCount} restantes)
</Button>
</div>
)}
</div>
)}
</Page>
<PreviewModal
open={previewModalOpen}
@@ -259,7 +272,6 @@ export default function History() {
generationStatus={previewData.generationStatus}
generationProgress={previewData.generationProgress}
/>
</main>
</div>
</>
);
}
+4 -117
View File
@@ -1,9 +1,9 @@
import { useState, useEffect, useRef } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { toPng } from 'html-to-image';
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -39,7 +39,6 @@ interface Logo {
}
export default function ImageEditor() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [selectedMedia, setSelectedMedia] = useState<any>(null);
const [ribbon, setRibbon] = useState<Ribbon>({
@@ -119,117 +118,7 @@ export default function ImageEditor() {
}, [selectedMedia, ribbon, priceBadge, logo]);
return (
<div className="flex h-screen overflow-hidden bg-background">
<style>{`
/* Triangle ribbon - auto-centered text */
.ribbon-container {
position: absolute;
z-index: 10;
pointer-events: none;
}
.ribbon-container.north_west {
top: 0;
left: 0;
width: 140px;
height: 140px;
}
.ribbon-container.north_east {
top: 0;
right: 0;
width: 140px;
height: 140px;
}
.ribbon-triangle {
position: absolute;
width: 0;
height: 0;
border-style: solid;
}
.ribbon-container.north_west .ribbon-triangle {
top: 0;
left: 0;
border-width: 140px 140px 0 0;
}
.ribbon-container.north_west .ribbon-triangle.red {
border-top-color: #FF0000;
border-right-color: transparent;
border-bottom-color: transparent;
border-left-color: transparent;
}
.ribbon-container.north_west .ribbon-triangle.yellow {
border-top-color: #FFC107;
border-right-color: transparent;
border-bottom-color: transparent;
border-left-color: transparent;
}
.ribbon-container.north_east .ribbon-triangle {
top: 0;
right: 0;
border-width: 0 140px 140px 0;
}
.ribbon-container.north_east .ribbon-triangle.red {
border-top-color: transparent;
border-right-color: #FF0000;
border-bottom-color: transparent;
border-left-color: transparent;
}
.ribbon-container.north_east .ribbon-triangle.yellow {
border-top-color: transparent;
border-right-color: #FFC107;
border-bottom-color: transparent;
border-left-color: transparent;
}
.ribbon-text {
position: absolute;
color: white;
font-weight: bold;
z-index: 11;
text-transform: uppercase;
text-align: center;
white-space: nowrap;
}
.ribbon-container.north_west .ribbon-text {
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg) translateY(-20px);
}
.ribbon-container.north_east .ribbon-text {
top: 50%;
right: 50%;
transform: translate(50%, -50%) rotate(45deg) translateY(-20px);
}
`}</style>
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto">
<Page width="wide">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<Wand2 className="w-8 h-8" />
@@ -634,8 +523,6 @@ export default function ImageEditor() {
</Card>
</div>
</div>
</div>
</main>
</div>
</Page>
);
}
+6 -25
View File
@@ -1,4 +1,6 @@
import { useState, useCallback, useRef, useMemo } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useLocation } from "wouter";
import { Send, Sparkles, Image as ImageIcon, Calendar, Upload, Camera, GripVertical, Loader2 } from "lucide-react";
@@ -20,8 +22,6 @@ import {
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";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
@@ -113,7 +113,6 @@ function startOfToday(): Date {
}
export default function NewPost() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [, navigate] = useLocation();
const { toast } = useToast();
const cameraInputRef = useRef<HTMLInputElement>(null);
@@ -385,25 +384,8 @@ export default function NewPost() {
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto">
<>
<Page width="default">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Nouvelle publication</h1>
<p className="text-muted-foreground mt-2">
@@ -767,8 +749,7 @@ export default function NewPost() {
</Button>
</div>
</div>
</div>
</main>
</Page>
<Dialog open={uploadMutation.isPending}>
<DialogContent className="sm:max-w-md [&>button]:hidden">
@@ -797,6 +778,6 @@ export default function NewPost() {
onPublish={handleCreatePost}
isPublishing={createPostMutation.isPending}
/>
</div>
</>
);
}
+7 -26
View File
@@ -1,4 +1,6 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useLocation } from "wouter";
import {
@@ -7,8 +9,6 @@ import {
ChevronRight, Loader2, Check, RefreshCw, Mic
} from "lucide-react";
import { useDropzone } from "react-dropzone";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
@@ -55,7 +55,6 @@ interface MusicTrack {
type Step = 'video' | 'music' | 'text' | 'publish';
export default function NewReel() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [, navigate] = useLocation();
const { toast } = useToast();
const audioRef = useRef<HTMLAudioElement>(null);
@@ -443,25 +442,8 @@ export default function NewReel() {
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto">
<>
<Page width="default">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2">
<Video className="w-8 h-8 text-primary" />
@@ -1069,9 +1051,8 @@ export default function NewReel() {
</CardContent>
</Card>
</div>
</div >
</div >
</main >
</div >
</div>
</Page>
</>
);
}
+212 -195
View File
@@ -1,12 +1,31 @@
import { useEffect, useState } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, AlertTriangle } from "lucide-react";
import { SiTiktok } from "react-icons/si";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Plus, Trash2, RefreshCw, Bug, Code, ChevronDown, HelpCircle, KeyRound, Link2, MoreVertical } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { EmptyState } from "@/components/empty-state";
import { PlatformIcon, platformLabel } from "@/components/platform-icon";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -14,16 +33,16 @@ import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import type { ClientSocialPage } from "@shared/schema";
import {
ConnectFacebookButton,
useConnectFacebook,
RefreshTokenButton,
TokenAlertBanner,
TokenHealthPanel,
} from "@/components/facebook-token-status";
export default function PagesManagement() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
const [pageToDelete, setPageToDelete] = useState<ClientSocialPage | null>(null);
const { toast } = useToast();
// Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback
@@ -56,8 +75,8 @@ export default function PagesManagement() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
toast({
title: "Page supprimée",
description: "La page a été retirée avec succès",
title: "Compte déconnecté",
description: "Le compte a été retiré de Social Flow",
});
},
onError: () => {
@@ -69,180 +88,189 @@ export default function PagesManagement() {
},
});
const connectFacebook = useConnectFacebook();
const connectTiktok = useConnectTiktok();
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-foreground">Pages gérées</h1>
<p className="text-muted-foreground mt-2">
Connectez et gérez vos pages Facebook, Instagram et vos comptes TikTok
</p>
</div>
<div className="flex items-center gap-3">
<ConnectFacebookButton />
<ConnectTiktokButton />
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</div>
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
</div>
<TokenAlertBanner pages={pages} />
{/* Permissions required notice */}
<div className="mb-6 p-4 bg-amber-500/10 border border-amber-500/30 rounded-xl flex gap-3">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-semibold text-amber-600 dark:text-amber-400 mb-1">Connectez vos pages plutôt que de coller un jeton</p>
<p className="text-muted-foreground mb-2">
« Connecter des pages Facebook » récupère les jetons de page via l'autorisation Facebook :
ils n'expirent pas et SocialFlow les régénère seul en cas de révocation. Un jeton collé à la
main expire au bout de 60 jours et devra être remplacé manuellement.
</p>
<p className="text-muted-foreground text-xs">
Le jeton doit couvrir : <code className="bg-muted px-1 rounded text-xs">pages_manage_posts</code>{" "}
<code className="bg-muted px-1 rounded text-xs">pages_read_engagement</code>{" "}
<code className="bg-muted px-1 rounded text-xs">publish_video</code>
</p>
</div>
</div>
<div className="flex gap-3 mb-6">
<Button
variant="outline"
onClick={() => window.open('https://developers.facebook.com/tools/debug/accesstoken/', '_blank')}
data-testid="button-debug-token"
>
<Bug className="w-4 h-4 mr-2" />
Débogueur de jeton
</Button>
<Button
variant="outline"
onClick={() => window.open('https://developers.facebook.com/tools/explorer', '_blank')}
data-testid="button-graph-explorer"
>
<Code className="w-4 h-4 mr-2" />
Graph Explorer
</Button>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="h-48 bg-card rounded-2xl border border-border/50 animate-pulse" />
))}
</div>
) : pages.length === 0 ? (
<Card className="border-dashed rounded-2xl border-border/50 shadow-lg">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
<Plus className="w-8 h-8 text-muted-foreground" />
<Page width="default">
<PageHeader
icon={Link2}
title="Comptes connectés"
description="Les pages Facebook, Instagram et comptes TikTok sur lesquels Social Flow publie."
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button data-testid="button-connect-account">
<Plus className="h-4 w-4" /> Connecter un compte <ChevronDown className="h-4 w-4 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuItem onClick={connectFacebook} className="items-start gap-3 py-2.5" data-testid="button-connect-facebook">
<PlatformIcon platform="facebook" size="sm" />
<div>
<p className="font-medium">Facebook & Instagram</p>
<p className="text-xs text-muted-foreground">Recommandé · connexion permanente</p>
</div>
<h3 className="text-lg font-semibold mb-2">Aucune page connectée</h3>
<p className="text-muted-foreground text-center mb-4">
Commencez par connecter vos pages Facebook et Instagram
</p>
<Button onClick={() => setDialogOpen(true)} data-testid="button-add-first-page">
<Plus className="w-4 h-4 mr-2" />
Ajouter une page
</Button>
</DropdownMenuItem>
<DropdownMenuItem onClick={connectTiktok} className="items-start gap-3 py-2.5" data-testid="button-connect-tiktok">
<PlatformIcon platform="tiktok" size="sm" />
<div>
<p className="font-medium">TikTok</p>
<p className="text-xs text-muted-foreground">Autorisation via TikTok</p>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setDialogOpen(true)} className="items-start gap-3 py-2.5" data-testid="button-add-page">
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground">
<KeyRound className="h-3.5 w-3.5" />
</span>
<div>
<p className="font-medium">Ajout manuel</p>
<p className="text-xs text-muted-foreground">Avec un jeton d'accès (expire après 60 jours)</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
<TokenAlertBanner pages={pages} />
{isLoading ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{[1, 2].map((i) => (
<div key={i} className="skeleton h-52 rounded-xl" />
))}
</div>
) : pages.length === 0 ? (
<EmptyState
icon={Link2}
title="Aucun compte connecté"
description="Connectez vos pages Facebook (et les comptes Instagram associés) ou un compte TikTok pour commencer à publier."
action={
<div className="flex flex-col gap-2 sm:flex-row">
<Button onClick={connectFacebook} data-testid="button-add-first-page">
<PlatformIcon platform="facebook" size="sm" className="h-5 w-5 bg-transparent text-current" />
Connecter Facebook
</Button>
<Button variant="outline" onClick={connectTiktok}>
<PlatformIcon platform="tiktok" size="sm" className="h-5 w-5 bg-transparent" />
Connecter TikTok
</Button>
</div>
}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{pages.map((page) => (
<Card key={page.id} className="flex flex-col" data-testid={`card-page-${page.id}`}>
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4">
<PlatformIcon platform={page.platform} size="lg" />
<div className="min-w-0 flex-1">
<CardTitle className="truncate">{page.pageName}</CardTitle>
<CardDescription className="truncate">
{platformLabel(page.platform)} · <span className="font-mono text-xs">{page.pageId}</span>
</CardDescription>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="shrink-0 text-muted-foreground" aria-label="Plus d'actions" data-testid={`button-page-menu-${page.id}`}>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{page.platform === "tiktok" ? (
<DropdownMenuItem onClick={() => { window.location.href = "/api/tiktok/connect"; }} data-testid={`button-reconnect-page-${page.id}`}>
<RefreshCw className="mr-2 h-4 w-4" /> Reconnecter
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem onClick={() => setEditingPage(page)} data-testid={`button-edit-page-${page.id}`}>
<KeyRound className="mr-2 h-4 w-4" /> Remplacer le jeton
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.open(`https://developers.facebook.com/tools/debug/accesstoken/`, "_blank")} data-testid="button-debug-token">
<Bug className="mr-2 h-4 w-4" /> Déboguer le jeton
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setPageToDelete(page)}
className="text-destructive focus:text-destructive"
data-testid={`button-delete-page-${page.id}`}
>
<Trash2 className="mr-2 h-4 w-4" /> Déconnecter
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3">
{/* État du jeton, tel que constaté par le dernier contrôle serveur */}
<TokenHealthPanel page={page} />
{page.platform !== "tiktok" && (
<div className="mt-auto flex justify-end">
<RefreshTokenButton page={page} />
</div>
)}
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{pages.map((page) => (
<Card key={page.id} className="rounded-2xl border-border/50 shadow-lg" data-testid={`card-page-${page.id}`}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
{page.platform === 'facebook' ? (
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
<Facebook className="w-6 h-6 text-white" />
</div>
) : page.platform === 'tiktok' ? (
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center">
<SiTiktok className="w-6 h-6 text-white" />
</div>
) : (
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
<Instagram className="w-6 h-6 text-white" />
</div>
)}
<div>
<CardTitle className="text-lg">{page.pageName}</CardTitle>
<CardDescription className="capitalize">
{page.platform}
</CardDescription>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-sm text-muted-foreground">
ID: {page.pageId}
</div>
{/* État du jeton, tel que constaté par le dernier contrôle serveur */}
<TokenHealthPanel page={page} />
<div className="flex items-center justify-end gap-2 pt-2">
{page.platform !== 'tiktok' && <RefreshTokenButton page={page} />}
{page.platform === 'tiktok' ? (
// Un token TikTok ne se saisit pas : on repasse par l'autorisation
<Button
variant="ghost"
size="sm"
onClick={() => { window.location.href = '/api/tiktok/connect'; }}
data-testid={`button-reconnect-page-${page.id}`}
>
<RefreshCw className="w-4 h-4 mr-2" />
Reconnecter
</Button>
) : (
<Button
variant="ghost"
size="icon"
onClick={() => setEditingPage(page)}
data-testid={`button-edit-page-${page.id}`}
>
<Edit className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate(page.id)}
disabled={deleteMutation.isPending}
data-testid={`button-delete-page-${page.id}`}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
))}
</div>
</main>
</div>
)}
<Collapsible className="mt-8 rounded-xl border bg-card">
<CollapsibleTrigger className="group flex w-full items-center gap-3 p-4 text-left text-sm font-medium">
<HelpCircle className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Besoin d'aide pour connecter un compte ?</span>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 border-t px-4 pb-4 pt-3 text-sm text-muted-foreground">
<p>
<strong className="text-foreground">Préférez « Facebook & Instagram »</strong> plutôt que l'ajout manuel :
les jetons obtenus ainsi n'expirent pas et Social Flow les régénère seul en cas de révocation. Un jeton
collé à la main expire au bout de 60 jours et devra être remplacé.
</p>
<p className="text-xs">
Permissions nécessaires : <code className="rounded bg-muted px-1">pages_manage_posts</code>{" "}
<code className="rounded bg-muted px-1">pages_read_engagement</code>{" "}
<code className="rounded bg-muted px-1">publish_video</code>
</p>
<div className="flex flex-wrap gap-2 pt-1">
<Button variant="outline" size="sm" onClick={() => window.open("https://developers.facebook.com/tools/debug/accesstoken/", "_blank")}>
<Bug className="h-4 w-4" /> Débogueur de jeton
</Button>
<Button variant="outline" size="sm" onClick={() => window.open("https://developers.facebook.com/tools/explorer", "_blank")} data-testid="button-graph-explorer">
<Code className="h-4 w-4" /> Graph Explorer
</Button>
</div>
</CollapsibleContent>
</Collapsible>
<AlertDialog open={!!pageToDelete} onOpenChange={(open) => !open && setPageToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Déconnecter « {pageToDelete?.pageName} » ?</AlertDialogTitle>
<AlertDialogDescription>
Social Flow ne pourra plus publier sur ce compte. Vous pourrez le reconnecter à tout moment.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (pageToDelete) deleteMutation.mutate(pageToDelete.id);
setPageToDelete(null);
}}
>
Déconnecter
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Page>
);
}
@@ -250,7 +278,7 @@ export default function PagesManagement() {
* Lance l'autorisation TikTok. La connexion se fait par navigation complète du
* navigateur (et non en fetch) puisqu'elle passe par le site de TikTok.
*/
function ConnectTiktokButton() {
function useConnectTiktok() {
const { toast } = useToast();
const { data: config } = useQuery<{ configured: boolean }>({
@@ -269,12 +297,7 @@ function ConnectTiktokButton() {
window.location.href = '/api/tiktok/connect';
};
return (
<Button variant="outline" onClick={handleClick} data-testid="button-connect-tiktok">
<SiTiktok className="w-4 h-4 mr-2" />
Connecter un compte TikTok
</Button>
);
return handleClick;
}
function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
@@ -320,17 +343,11 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button data-testid="button-add-page">
<Plus className="w-4 h-4 mr-2" />
Ajouter une page
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Ajouter une page</DialogTitle>
<DialogTitle>Ajouter un compte manuellement</DialogTitle>
<DialogDescription>
Connectez une page Facebook ou Instagram pour commencer à publier
Renseignez l'identifiant de la page et un jeton d'accès. Ce jeton expirera au bout de 60 jours.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
@@ -411,7 +428,7 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
>
{addMutation.isPending ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
<RefreshCw className="w-4 h-4 animate-spin" />
Ajout...
</>
) : (
@@ -510,7 +527,7 @@ function EditPageDialog({ page, onOpenChange }: { page: ClientSocialPage | null;
>
{editMutation.isPending ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
<RefreshCw className="w-4 h-4 animate-spin" />
Modification...
</>
) : (
+4 -22
View File
@@ -1,10 +1,10 @@
import { useState, useCallback } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useLocation } from "wouter";
import { CalendarClock, Upload, Loader2, Check, Video } from "lucide-react";
import { useDropzone } from "react-dropzone";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
@@ -23,7 +23,6 @@ import { DateTimePicker } from "@/components/datetime-picker";
* publiée telle quelle par le planificateur à la date choisie.
*/
export default function ScheduleReel() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [, navigate] = useLocation();
const { toast } = useToast();
@@ -125,22 +124,7 @@ export default function ScheduleReel() {
);
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-4xl mx-auto space-y-6">
<Page width="narrow" className="space-y-6">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground flex items-center gap-2">
<CalendarClock className="w-8 h-8 text-primary" />
@@ -293,8 +277,6 @@ export default function ScheduleReel() {
</Button>
</CardContent>
</Card>
</div>
</main>
</div>
</Page>
);
}
+4 -25
View File
@@ -1,8 +1,8 @@
import { useState, useEffect } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic, Facebook } from "lucide-react";
import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -16,7 +16,6 @@ import { QwenSettingsCard } from "@/components/reels/qwen-settings-card";
import { SiTiktok } from "react-icons/si";
export default function Settings() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [notifications, setNotifications] = useState(true);
const [autoPublish, setAutoPublish] = useState(true);
const [cloudName, setCloudName] = useState("");
@@ -418,25 +417,7 @@ export default function Settings() {
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-4xl mx-auto">
<Page width="default">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Paramètres</h1>
<p className="text-muted-foreground mt-2">
@@ -1010,8 +991,6 @@ export default function Settings() {
</CardContent>
</Card>
</div>
</div>
</main>
</div>
</Page>
);
}
+56 -72
View File
@@ -1,17 +1,16 @@
import { useState } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Database, Download, Play } from "lucide-react";
import { Database, Download, Play, Table2 } from "lucide-react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
export default function SqlAdmin() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { toast } = useToast();
const [sqlQuery, setSqlQuery] = useState("");
const [queryResult, setQueryResult] = useState<any>(null);
@@ -75,77 +74,60 @@ export default function SqlAdmin() {
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<Page width="wide">
<PageHeader
icon={Database}
title="Base de données"
description="Exécutez des requêtes SQL directement sur la base. À utiliser avec précaution."
/>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto space-y-6 sm:space-y-8">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<Database className="w-8 h-8" />
Administration SQL
</h1>
<p className="text-muted-foreground mt-2">
Exécutez des requêtes SQL et gérez votre base de données
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-8">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
{/* Liste des tables */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle>Tables disponibles</CardTitle>
<CardDescription>
Cliquez pour insérer dans la requête
</CardDescription>
<Card className="h-fit lg:sticky lg:top-6">
<CardHeader className="pb-3">
<CardTitle>Tables</CardTitle>
<CardDescription>Cliquez pour pré-remplir la requête</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{(tablesData as any)?.tables?.map((table: any) => (
<Button
key={table.tablename}
variant="outline"
className="w-full justify-start text-left"
onClick={() => insertTable(table.tablename)}
data-testid={`button-table-${table.tablename}`}
>
<Database className="w-4 h-4 mr-2" />
{table.tablename}
</Button>
))}
</div>
{(tablesData as any)?.tables?.length ? (
<div className="flex max-h-[60vh] flex-wrap gap-1.5 overflow-y-auto lg:flex-col lg:flex-nowrap">
{(tablesData as any).tables.map((table: any) => (
<button
key={table.tablename}
className="flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left font-mono text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground max-lg:border"
onClick={() => insertTable(table.tablename)}
data-testid={`button-table-${table.tablename}`}
>
<Table2 className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{table.tablename}</span>
</button>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Aucune table trouvée.</p>
)}
</CardContent>
</Card>
{/* Éditeur SQL et résultats */}
<div className="lg:col-span-3 space-y-8">
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle>Éditeur SQL</CardTitle>
<CardDescription>
Entrez votre requête SQL ci-dessous
</CardDescription>
<div className="space-y-6 lg:col-span-3">
<Card>
<CardHeader>
<CardTitle>Requête</CardTitle>
<CardDescription>Ctrl + Entrée pour exécuter</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
placeholder="SELECT * FROM users LIMIT 10;"
value={sqlQuery}
onChange={(e) => setSqlQuery(e.target.value)}
className="font-mono min-h-[200px]"
className="min-h-[200px] font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleExecuteQuery();
}
}}
data-testid="textarea-sql-query"
/>
@@ -155,7 +137,7 @@ export default function SqlAdmin() {
disabled={executeSqlMutation.isPending}
data-testid="button-execute-sql"
>
<Play className="w-4 h-4 mr-2" />
<Play className="h-4 w-4" />
{executeSqlMutation.isPending ? "Exécution..." : "Exécuter"}
</Button>
@@ -172,13 +154,17 @@ export default function SqlAdmin() {
{/* Résultats */}
{queryResult && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<div className="flex justify-between items-center">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Résultats</CardTitle>
<CardDescription>
{queryResult.success ? "Requête exécutée avec succès" : "Erreur lors de l'exécution"}
{queryResult.success
? Array.isArray(queryResult.result)
? `${queryResult.result.length} ligne(s)`
: "Requête exécutée avec succès"
: "Erreur lors de l'exécution"}
</CardDescription>
</div>
{queryResult.success && queryResult.result && (
@@ -188,7 +174,7 @@ export default function SqlAdmin() {
onClick={handleDownloadResult}
data-testid="button-download-result"
>
<Download className="w-4 h-4 mr-2" />
<Download className="h-4 w-4" />
Télécharger JSON
</Button>
)}
@@ -196,7 +182,7 @@ export default function SqlAdmin() {
</CardHeader>
<CardContent>
{queryResult.success ? (
<div className="overflow-x-auto">
<div className="max-h-[60vh] overflow-auto rounded-lg border">
{Array.isArray(queryResult.result) && queryResult.result.length > 0 ? (
<Table>
<TableHeader>
@@ -225,7 +211,7 @@ export default function SqlAdmin() {
</TableBody>
</Table>
) : (
<pre className="bg-muted p-4 rounded-lg overflow-auto max-h-96">
<pre className="max-h-96 overflow-auto bg-muted p-4 text-xs">
<code>{JSON.stringify(queryResult.result, null, 2)}</code>
</pre>
)}
@@ -241,8 +227,6 @@ export default function SqlAdmin() {
)}
</div>
</div>
</div>
</main>
</div>
</Page>
);
}
+150 -190
View File
@@ -1,20 +1,22 @@
import { useState, useEffect } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { EmptyState } from "@/components/empty-state";
import { PlatformIcon, platformLabel } from "@/components/platform-icon";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Users, UserPlus, Pencil, Trash2, Shield, User as UserIcon, Settings } from "lucide-react";
import { Users, UserPlus, Pencil, Trash2, Shield, User as UserIcon, KeyRound } from "lucide-react";
type UserData = {
id: string;
@@ -36,13 +38,13 @@ type UserPagePermission = {
};
export default function UsersAdmin() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { toast } = useToast();
// Form states
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<"admin" | "user">("user");
const [createDialogOpen, setCreateDialogOpen] = useState(false);
// Edit dialog states
const [editDialogOpen, setEditDialogOpen] = useState(false);
@@ -88,6 +90,7 @@ export default function UsersAdmin() {
description: `L'utilisateur ${data.username} a été créé avec succès`,
});
setUsername("");
setCreateDialogOpen(false);
setPassword("");
setRole("user");
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
@@ -278,190 +281,144 @@ export default function UsersAdmin() {
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
<>
<Page width="default">
<PageHeader
icon={Users}
title="Utilisateurs"
description="Qui peut se connecter à Social Flow et sur quels comptes publier."
actions={
<Button onClick={() => setCreateDialogOpen(true)} data-testid="button-open-create-user">
<UserPlus className="h-4 w-4" /> Nouvel utilisateur
</Button>
}
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto space-y-6 sm:space-y-8">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<Users className="w-8 h-8" />
Gestion des utilisateurs
</h1>
<p className="text-muted-foreground mt-2">
Créez et gérez les utilisateurs de l'application
</p>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="skeleton h-16 rounded-xl" />
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6 lg:gap-8">
{/* Liste des utilisateurs */}
<div className="lg:col-span-2">
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle>Liste des utilisateurs</CardTitle>
<CardDescription>
{users?.length || 0} utilisateur(s) enregistré(s)
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-4">Chargement...</p>
</div>
) : users && users.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom d'utilisateur</TableHead>
<TableHead>Rôle</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium flex items-center gap-2">
{user.role === "admin" ? (
<Shield className="w-4 h-4 text-primary" />
) : (
<UserIcon className="w-4 h-4 text-muted-foreground" />
)}
{user.username}
</TableCell>
<TableCell>
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
{user.role === "admin" ? "Administrateur" : "Utilisateur"}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{user.role === "user" && (
<Button
variant="outline"
size="sm"
onClick={() => handlePermissionsClick(user)}
data-testid={`button-permissions-user-${user.id}`}
>
<Settings className="w-4 h-4" />
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleEditClick(user)}
data-testid={`button-edit-user-${user.id}`}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteClick(user)}
data-testid={`button-delete-user-${user.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8">
<p className="text-muted-foreground">Aucun utilisateur trouvé</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* Formulaire de création */}
<div>
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
<UserPlus className="w-5 h-5" />
Créer un utilisateur
</CardTitle>
<CardDescription>
Ajouter un nouvel utilisateur
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Nom d'utilisateur</Label>
<Input
id="username"
type="text"
placeholder="utilisateur123"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
data-testid="input-new-username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
data-testid="input-new-password"
/>
<p className="text-xs text-muted-foreground">
Minimum 4 caractères
</p>
</div>
<div className="space-y-2">
<Label htmlFor="role">Rôle</Label>
<Select value={role} onValueChange={(value: "admin" | "user") => setRole(value)}>
<SelectTrigger id="role" data-testid="select-user-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">Utilisateur</SelectItem>
<SelectItem value="admin">Administrateur</SelectItem>
</SelectContent>
</Select>
</div>
) : users && users.length > 0 ? (
<Card className="overflow-hidden">
<ul className="divide-y">
{users.map((user) => (
<li key={user.id} className="flex items-center gap-3 p-4" data-testid={`row-user-${user.id}`}>
<Avatar className="h-10 w-10">
<AvatarFallback className={user.role === "admin" ? "bg-primary/10 font-semibold text-primary" : "bg-muted font-semibold text-muted-foreground"}>
{user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{user.username}</p>
<Badge variant={user.role === "admin" ? "default" : "muted"} className="mt-1 gap-1">
{user.role === "admin" ? <Shield className="h-3 w-3" /> : <UserIcon className="h-3 w-3" />}
{user.role === "admin" ? "Administrateur" : "Utilisateur"}
</Badge>
</div>
<div className="flex shrink-0 items-center gap-1">
{user.role === "user" && (
<Button
variant="outline"
size="sm"
onClick={() => handlePermissionsClick(user)}
data-testid={`button-permissions-user-${user.id}`}
>
<KeyRound className="h-4 w-4" />
<span className="hidden sm:inline">Accès aux pages</span>
</Button>
)}
<Button
type="submit"
className="w-full"
disabled={createUserMutation.isPending}
data-testid="button-create-user"
variant="ghost"
size="icon"
className="text-muted-foreground"
onClick={() => handleEditClick(user)}
aria-label="Modifier"
data-testid={`button-edit-user-${user.id}`}
>
{createUserMutation.isPending ? "Création..." : "Créer"}
<Pencil className="h-4 w-4" />
</Button>
</form>
</CardContent>
</Card>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={() => handleDeleteClick(user)}
aria-label="Supprimer"
data-testid={`button-delete-user-${user.id}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
</Card>
) : (
<EmptyState
icon={Users}
title="Aucun utilisateur"
action={<Button onClick={() => setCreateDialogOpen(true)}>Créer un utilisateur</Button>}
/>
)}
</Page>
{/* Dialog de création */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Nouvel utilisateur</DialogTitle>
<DialogDescription>Il pourra se connecter immédiatement avec ces identifiants.</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Nom d'utilisateur</Label>
<Input
id="username"
type="text"
placeholder="utilisateur123"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
data-testid="input-new-username"
/>
</div>
</div>
</div>
</main>
<div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
data-testid="input-new-password"
/>
<p className="text-xs text-muted-foreground">4 caractères minimum</p>
</div>
<div className="space-y-2">
<Label htmlFor="role">Rôle</Label>
<Select value={role} onValueChange={(value: "admin" | "user") => setRole(value)}>
<SelectTrigger id="role" data-testid="select-user-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">Utilisateur — publie sur les pages autorisées</SelectItem>
<SelectItem value="admin">Administrateur — accès complet</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)}>
Annuler
</Button>
<Button type="submit" disabled={createUserMutation.isPending} data-testid="button-create-user">
{createUserMutation.isPending ? "Création…" : "Créer l'utilisateur"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Dialog de modification */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
@@ -525,7 +482,7 @@ export default function UsersAdmin() {
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmer la suppression</AlertDialogTitle>
<AlertDialogTitle>Supprimer cet utilisateur ?</AlertDialogTitle>
<AlertDialogDescription>
Êtes-vous sûr de vouloir supprimer l'utilisateur <strong>{userToDelete?.username}</strong> ?
Cette action est irréversible.
@@ -548,7 +505,7 @@ export default function UsersAdmin() {
<Dialog open={permissionsDialogOpen} onOpenChange={setPermissionsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Gérer les permissions</DialogTitle>
<DialogTitle>Accès aux pages</DialogTitle>
<DialogDescription>
Sélectionnez les pages auxquelles l'utilisateur <strong>{permissionsUser?.username}</strong> peut accéder
</DialogDescription>
@@ -557,7 +514,7 @@ export default function UsersAdmin() {
{allPages && allPages.length > 0 ? (
<div className="space-y-3">
{allPages.map((page) => (
<div key={page.id} className="flex items-center space-x-2 p-3 border rounded-lg hover:bg-accent">
<div key={page.id} className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent">
<Checkbox
id={`page-${page.id}`}
checked={selectedPageIds.includes(page.id)}
@@ -568,9 +525,12 @@ export default function UsersAdmin() {
htmlFor={`page-${page.id}`}
className="flex-1 cursor-pointer"
>
<div className="font-medium">{page.pageName}</div>
<div className="text-sm text-muted-foreground">
{page.platform === "facebook" ? "Facebook" : "Instagram"}
<div className="flex items-center gap-3">
<PlatformIcon platform={page.platform} size="sm" />
<div>
<div className="font-medium">{page.pageName}</div>
<div className="text-xs text-muted-foreground">{platformLabel(page.platform)}</div>
</div>
</div>
</Label>
</div>
@@ -596,6 +556,6 @@ export default function UsersAdmin() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</>
);
}