Refonte UI : médiathèque, calendrier, aperçu et statistiques sur les nouveaux tokens

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:56:22 +00:00
1 parent bf23ecd226
commit 7a7188e508
7 files changed
+325 -322

No files matched your search

@@ -2,6 +2,9 @@ import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { MetricsCard } from './MetricsCard';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Link } from 'wouter';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/empty-state';
import { Button } from '@/components/ui/button';
import { RefreshCw, Users, BarChart3, Eye, Heart } from 'lucide-react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
@@ -98,15 +101,34 @@ export function AnalyticsDashboard() {
return Math.round(((current - prev) / prev) * 100);
};
if (isLoadingPages) return <div>Chargement des pages...</div>;
if (!pages.length) return <div>Aucune page connectée. Veuillez connecter une page Facebook dans les Paramètres.</div>;
if (isLoadingPages) {
return (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => <div key={i} className="skeleton h-28 rounded-xl" />)}
</div>
);
}
if (!pages.length) {
return (
<EmptyState
icon={BarChart3}
title="Aucun compte connecté"
description="Connectez une page Facebook pour suivre son audience et son engagement."
action={
<Button asChild>
<Link href="/pages">Connecter un compte</Link>
</Button>
}
/>
);
}
return (
<div className="space-y-8">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex items-center gap-4">
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center gap-3">
<div className="flex items-center gap-3">
<Select value={selectedPageId || ''} onValueChange={setSelectedPageId}>
<SelectTrigger className="w-[250px]">
<SelectTrigger className="w-full sm:w-[260px]">
<SelectValue placeholder="Sélectionner une page" />
</SelectTrigger>
<SelectContent>
@@ -119,9 +141,9 @@ export function AnalyticsDashboard() {
</Select>
{selectedPage && (
<span className={`px-2 py-1 rounded text-xs ${selectedPage.tokenStatus === 'valid' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
Token : {selectedPage.tokenStatus || 'Inconnu'}
</span>
<Badge variant={selectedPage.tokenStatus === 'valid' ? 'success' : 'danger'} className="shrink-0">
{selectedPage.tokenStatus === 'valid' ? 'Connexion active' : 'Connexion à vérifier'}
</Badge>
)}
</div>
@@ -131,22 +153,22 @@ export function AnalyticsDashboard() {
disabled={refreshMutation.isPending || !selectedPageId}
>
<RefreshCw className={`h-4 w-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
Actualiser les données
Actualiser
</Button>
</div>
{selectedPageId && (
<>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
<MetricsCard
title="Abonnés Totaux"
title="Abonnés"
value={latest?.followersCount?.toLocaleString() || 0}
icon={<Users className="h-4 w-4 text-muted-foreground" />}
trend={latest && previous ? getTrend(latest.followersCount, previous.followersCount) : undefined}
description="depuis la dernière mise à jour"
/>
<MetricsCard
title="Portée de la Page"
title="Portée"
value={latest?.pageReach?.toLocaleString() || 0}
icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />}
trend={latest && previous ? getTrend(latest.pageReach, previous.pageReach) : undefined}
@@ -160,7 +182,7 @@ export function AnalyticsDashboard() {
description="interactions totales (jour)"
/>
<MetricsCard
title="Vues de Page"
title="Vues de la page"
value={(latest?.pageViews || 0).toLocaleString()}
icon={<Eye className="h-4 w-4 text-muted-foreground" />}
trend={latest && previous ? getTrend(latest.pageViews || 0, previous.pageViews || 0) : undefined}
@@ -170,22 +192,22 @@ export function AnalyticsDashboard() {
<Card>
<CardHeader>
<CardTitle>Historique de Croissance &amp; Portée</CardTitle>
<CardTitle>Évolution</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis yAxisId="left" />
<YAxis yAxisId="right" orientation="right" />
<Tooltip />
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis dataKey="date" stroke="hsl(var(--muted-foreground))" fontSize={12} tickLine={false} axisLine={false} />
<YAxis yAxisId="left" stroke="hsl(var(--muted-foreground))" fontSize={12} tickLine={false} axisLine={false} />
<YAxis yAxisId="right" orientation="right" stroke="hsl(var(--muted-foreground))" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip contentStyle={{ background: "hsl(var(--popover))", border: "1px solid hsl(var(--border))", borderRadius: 8, color: "hsl(var(--popover-foreground))" }} />
<Legend />
<Line yAxisId="left" type="monotone" dataKey="followers" stroke="#8884d8" name="Abonnés" />
<Line yAxisId="right" type="monotone" dataKey="reach" stroke="#82ca9d" name="Portée" />
<Line yAxisId="right" type="monotone" dataKey="engagement" stroke="#ff7f50" name="Engagement" />
<Line yAxisId="right" type="monotone" dataKey="views" stroke="#ffa500" name="Vues" />
<Line yAxisId="left" type="monotone" dataKey="followers" strokeWidth={2} dot={false} stroke="hsl(var(--chart-1))" name="Abonnés" />
<Line yAxisId="right" type="monotone" dataKey="reach" strokeWidth={2} dot={false} stroke="hsl(var(--chart-3))" name="Portée" />
<Line yAxisId="right" type="monotone" dataKey="engagement" strokeWidth={2} dot={false} stroke="hsl(var(--chart-2))" name="Engagement" />
<Line yAxisId="right" type="monotone" dataKey="views" strokeWidth={2} dot={false} stroke="hsl(var(--chart-5))" name="Vues" />
</LineChart>
</ResponsiveContainer>
</div>
@@ -13,17 +13,17 @@ export function MetricsCard({ title, value, description, trend, icon }: MetricsC
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
{icon && <div className="text-muted-foreground">{icon}</div>}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
<div className="text-2xl font-semibold tabular-nums">{value}</div>
{(trend !== undefined || description) && (
<p className="text-xs text-muted-foreground mt-1 flex items-center">
{trend !== undefined && (
<span className={`flex items-center mr-2 ${trend > 0 ? 'text-green-500' : trend < 0 ? 'text-red-500' : ''}`}>
<span className={`flex items-center mr-2 ${trend > 0 ? 'text-success' : trend < 0 ? 'text-destructive' : ''}`}>
{trend > 0 ? <ArrowUpIcon className="w-3 h-3 mr-1" /> : trend < 0 ? <ArrowDownIcon className="w-3 h-3 mr-1" /> : <MinusIcon className="w-3 h-3 mr-1" />}
{Math.abs(trend)}%
</span>
+6 -6
View File
@@ -165,7 +165,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
<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'
isPending ? 'bg-primary/5' : 'bg-success/5'
}`}
data-testid={`calendar-list-post-${post.id}`}
>
@@ -176,8 +176,8 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
<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'
? 'bg-primary/10 text-primary'
: 'bg-success/15 text-success'
}`}>
{isPending ? 'Programmé' : 'Publié'}
</span>
@@ -197,7 +197,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
data-testid={`button-preview-post-${post.id}`}
title="Prévisualiser"
>
<Eye className={`w-5 h-5 ${isPending ? 'text-blue-500' : 'text-green-500'}`} />
<Eye className={`w-5 h-5 ${isPending ? 'text-primary' : 'text-success'}`} />
</Button>
{isPending && (
<>
@@ -209,7 +209,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
data-testid={`button-edit-post-${post.id}`}
title="Modifier"
>
<Edit className="w-5 h-5 text-blue-500" />
<Edit className="w-5 h-5 text-primary" />
</Button>
<Button
variant="ghost"
@@ -219,7 +219,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
data-testid={`button-delete-post-${post.id}`}
title="Supprimer"
>
<Trash2 className="w-5 h-5 text-red-500" />
<Trash2 className="w-5 h-5 text-destructive" />
</Button>
</>
)}
+57 -62
View File
@@ -191,52 +191,47 @@ export default function CalendarView() {
};
return (
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg">
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg">
<CalendarIcon className="text-white w-6 h-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">Calendrier</h3>
<p className="text-sm text-muted-foreground">Visualisez vos publications planifiées</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={goToPreviousMonth}
className="rounded-xl"
data-testid="button-prev-month"
>
<ChevronLeft className="w-4 h-4" />
Précédent
</Button>
<span className="text-sm font-semibold text-foreground min-w-[140px] text-center px-4 py-2 bg-muted/30 rounded-xl">
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
</span>
<Button
variant="outline"
size="sm"
onClick={goToNextMonth}
className="rounded-xl"
data-testid="button-next-month"
>
Suivant
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
<div className="overflow-hidden rounded-xl border bg-card shadow-soft">
<div className="flex items-center justify-between gap-3 border-b px-4 py-3 sm:px-6">
<h2 className="text-lg font-semibold capitalize text-foreground">
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
</h2>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentDate(new Date())}
data-testid="button-today"
>
Aujourd'hui
</Button>
<Button
variant="ghost"
size="icon"
onClick={goToPreviousMonth}
aria-label="Mois précédent"
data-testid="button-prev-month"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goToNextMonth}
aria-label="Mois suivant"
data-testid="button-next-month"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
{isDesktop ? (
<div className="p-8">
<div className="p-4 lg:p-6">
<div className="grid grid-cols-7 gap-2 mb-2">
{daysOfWeek.map((day) => (
<div key={day} className="bg-muted/30 py-3 rounded-lg text-center">
<span className="text-sm font-semibold text-muted-foreground">{day}</span>
<div key={day} className="py-1 text-center">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{day}</span>
</div>
))}
</div>
@@ -246,8 +241,8 @@ export default function CalendarView() {
<div
key={index}
className={`
bg-card border border-border/50 rounded-xl p-4 min-h-[140px] transition-all hover:shadow-md
${isToday(day.date) ? "ring-2 ring-primary shadow-lg" : ""}
bg-card border rounded-lg p-2.5 min-h-[120px] transition-colors hover:border-primary/30
${isToday(day.date) ? "border-primary/60 bg-primary/[0.03]" : ""}
${!day.isCurrentMonth ? "opacity-40" : ""}
`}
data-testid={`calendar-day-${index}`}
@@ -263,7 +258,7 @@ export default function CalendarView() {
{day.date.getDate()}
</span>
{isToday(day.date) && (
<span className="text-[10px] bg-gradient-to-r from-primary to-secondary text-white px-2 py-0.5 rounded-full font-semibold">
<span className="text-[10px] bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-semibold">
Aujourd'hui
</span>
)}
@@ -289,8 +284,8 @@ export default function CalendarView() {
key={idx}
className={`
px-3 py-2 rounded-lg text-xs transition-all font-medium shadow-sm group relative
${isPending ? 'bg-blue-500/20 text-blue-600 hover:bg-blue-500/30' : ''}
${isPublished ? 'bg-green-500/20 text-green-600 hover:bg-green-500/30' : ''}
${isPending ? 'bg-primary/10 text-primary hover:bg-primary/20' : ''}
${isPublished ? 'bg-success/15 text-success hover:bg-success/25' : ''}
`}
data-testid={`calendar-post-${post.id}`}
>
@@ -308,29 +303,29 @@ export default function CalendarView() {
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => handlePreviewPost(post, e)}
className={`p-1 rounded ${isPending ? 'hover:bg-blue-500/20' : 'hover:bg-green-500/20'}`}
className={`p-1 rounded ${isPending ? 'hover:bg-primary/15' : 'hover:bg-success/20'}`}
data-testid={`button-preview-post-${post.id}`}
title="Prévisualiser"
>
<Eye className={`w-3 h-3 ${isPending ? 'text-blue-500' : 'text-green-500'}`} />
<Eye className={`w-3 h-3 ${isPending ? 'text-primary' : 'text-success'}`} />
</button>
{isPending && (
<>
<button
onClick={(e) => handleEditPost(post, e)}
className="p-1 hover:bg-blue-500/20 rounded"
className="p-1 hover:bg-primary/15 rounded"
data-testid={`button-edit-post-${post.id}`}
title="Modifier"
>
<Edit className="w-3 h-3 text-blue-500" />
<Edit className="w-3 h-3 text-primary" />
</button>
<button
onClick={(e) => handleDeletePost(post.id, e)}
className="p-1 hover:bg-red-500/20 rounded"
className="p-1 hover:bg-destructive/15 rounded"
data-testid={`button-delete-post-${post.id}`}
title="Supprimer"
>
<Trash2 className="w-3 h-3 text-red-500" />
<Trash2 className="w-3 h-3 text-destructive" />
</button>
</>
)}
@@ -368,8 +363,8 @@ export default function CalendarView() {
key={idx}
className={`
px-3 py-2 rounded-lg text-xs transition-all font-medium shadow-sm group relative
${isPending ? 'bg-blue-500/20 text-blue-600 hover:bg-blue-500/30' : ''}
${isPublished ? 'bg-green-500/20 text-green-600 hover:bg-green-500/30' : ''}
${isPending ? 'bg-primary/10 text-primary hover:bg-primary/20' : ''}
${isPublished ? 'bg-success/15 text-success hover:bg-success/25' : ''}
`}
data-testid={`popover-post-${post.id}`}
>
@@ -387,29 +382,29 @@ export default function CalendarView() {
<div className="flex gap-1">
<button
onClick={(e) => handlePreviewPost(post, e)}
className={`p-1 rounded ${isPending ? 'hover:bg-blue-500/20' : 'hover:bg-green-500/20'}`}
className={`p-1 rounded ${isPending ? 'hover:bg-primary/15' : 'hover:bg-success/20'}`}
data-testid={`button-preview-popover-post-${post.id}`}
title="Prévisualiser"
>
<Eye className={`w-3 h-3 ${isPending ? 'text-blue-500' : 'text-green-500'}`} />
<Eye className={`w-3 h-3 ${isPending ? 'text-primary' : 'text-success'}`} />
</button>
{isPending && (
<>
<button
onClick={(e) => handleEditPost(post, e)}
className="p-1 hover:bg-blue-500/20 rounded"
className="p-1 hover:bg-primary/15 rounded"
data-testid={`button-edit-popover-post-${post.id}`}
title="Modifier"
>
<Edit className="w-3 h-3 text-blue-500" />
<Edit className="w-3 h-3 text-primary" />
</button>
<button
onClick={(e) => handleDeletePost(post.id, e)}
className="p-1 hover:bg-red-500/20 rounded"
className="p-1 hover:bg-destructive/15 rounded"
data-testid={`button-delete-popover-post-${post.id}`}
title="Supprimer"
>
<Trash2 className="w-3 h-3 text-red-500" />
<Trash2 className="w-3 h-3 text-destructive" />
</button>
</>
)}
@@ -429,13 +424,13 @@ export default function CalendarView() {
))}
</div>
<div className="mt-8 flex items-center justify-center gap-8 flex-wrap">
<div className="mt-5 flex items-center justify-center gap-6 flex-wrap">
<div className="flex items-center gap-3">
<div className="w-4 h-4 rounded-md bg-blue-500/20 border-2 border-blue-500"></div>
<div className="w-3 h-3 rounded bg-primary/15 border-2 border-primary"></div>
<span className="text-sm text-muted-foreground font-medium">Programmé</span>
</div>
<div className="flex items-center gap-3">
<div className="w-4 h-4 rounded-md bg-green-500/20 border-2 border-green-500"></div>
<div className="w-3 h-3 rounded bg-success/15 border-2 border-success"></div>
<span className="text-sm text-muted-foreground font-medium">Publié</span>
</div>
</div>
+7 -7
View File
@@ -100,7 +100,7 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
occupied: (date) => isDateOccupied(date)
}}
modifiersClassNames={{
occupied: "bg-red-500/20 text-red-700 dark:text-red-300 font-bold hover:bg-red-500/30"
occupied: "bg-destructive/15 text-destructive font-bold hover:bg-destructive/25"
}}
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
data-testid="calendar-picker"
@@ -154,7 +154,7 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
className={cn(
"w-full justify-center mb-0.5 h-9 text-xs",
selectedHour === hour && "bg-primary text-primary-foreground",
isOptimalHour(hour) && selectedHour !== hour && "bg-green-500/20 text-green-700 dark:text-green-300 font-semibold hover:bg-green-500/30"
isOptimalHour(hour) && selectedHour !== hour && "bg-success/15 text-success font-semibold hover:bg-success/25"
)}
onClick={() => handleTimeChange(hour, selectedMinute)}
data-testid={`hour-${hour}`}
@@ -189,8 +189,8 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
</div>
<div className="p-2 border-t bg-muted/50">
<div className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-green-500"></div>
<span className="text-[9px] font-semibold text-green-600 dark:text-green-400">
<div className="w-1.5 h-1.5 rounded-full bg-success"></div>
<span className="text-[9px] font-semibold text-success">
Heures optimales (8h, 12h-13h, 18h-20h)
</span>
</div>
@@ -200,10 +200,10 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
)}
{occupiedDates.length > 0 && (
<div className="p-3 bg-red-500/10 rounded-lg border border-red-500/20">
<div className="p-3 bg-destructive/10 rounded-lg border border-destructive/20">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-500"></div>
<span className="text-xs font-semibold text-red-600 dark:text-red-400">
<div className="w-2 h-2 rounded-full bg-destructive"></div>
<span className="text-xs font-semibold text-destructive">
Les dates en fond rouge ont déjà des publications programmées
</span>
</div>
+183 -187
View File
@@ -5,7 +5,18 @@ import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useInfiniteQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import { MediaThumbnail } from "@/components/media-thumbnail";
import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react";
import { CloudUpload, Image as ImageIcon, X, Upload, Loader2, ZoomIn, Camera, Trash2 } from "lucide-react";
import { EmptyState } from "@/components/empty-state";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
import { SiFacebook, SiInstagram } from "react-icons/si";
@@ -16,6 +27,7 @@ export default function MediaUpload() {
const { toast } = useToast();
const [selectedFile, setSelectedFile] = useState<any>(null);
const [zoomImage, setZoomImage] = useState<string | null>(null);
const [mediaToDelete, setMediaToDelete] = useState<any>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
@@ -213,219 +225,203 @@ export default function MediaUpload() {
};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const formatPreview = (label: string, size: string, url: string | undefined, aspect: string, width: string) => (
<div className="rounded-lg border p-3">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<SiFacebook className="text-facebook" />
<SiInstagram className="text-instagram" />
<span className="text-sm font-medium">{label}</span>
</div>
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">{size}</span>
</div>
<div className={`mx-auto overflow-hidden rounded-md border bg-muted/40 ${aspect} ${width}`}>
{url ? (
<img src={url} alt={`Aperçu ${label}`} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center">
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
</div>
)}
</div>
</div>
);
return (
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg">
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg">
<Upload className="text-white w-6 h-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">Médiathèque</h3>
<p className="text-sm text-muted-foreground">Téléchargement et recadrage automatique</p>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={() => cameraInputRef.current?.click()}
disabled={uploadMutation.isPending}
variant="outline"
className="lg:hidden"
data-testid="button-camera"
>
<Camera className="w-4 h-4" />
</Button>
<Button
onClick={open}
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90 rounded-xl"
data-testid="button-browse"
>
<CloudUpload className="w-4 h-4" />
Parcourir
</Button>
</div>
<div className="space-y-6">
<input
ref={cameraInputRef}
type="file"
accept="image/*,video/*"
capture="environment"
onChange={handleCameraCapture}
className="hidden"
/>
<div
{...getRootProps()}
className={`
flex cursor-pointer flex-col items-center gap-4 rounded-xl border-2 border-dashed bg-card p-6 text-center transition-colors sm:flex-row sm:text-left
${isDragActive ? "border-primary bg-primary/5" : "hover:border-primary/50"}
${uploadingCount > 0 ? "pointer-events-none opacity-60" : ""}
`}
data-testid="dropzone-upload"
>
<input {...getInputProps()} />
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
{uploadingCount > 0 ? <Loader2 className="h-6 w-6 animate-spin" /> : <CloudUpload className="h-6 w-6" />}
</div>
<div className="flex-1">
{uploadingCount > 0 ? (
<>
<p className="font-medium">Import en cours…</p>
<p className="text-sm text-muted-foreground">{uploadingCount} fichier(s) en cours de traitement</p>
</>
) : (
<>
<p className="font-medium">{isDragActive ? "Déposez vos fichiers ici" : "Glissez-déposez vos images et vidéos"}</p>
<p className="text-sm text-muted-foreground">PNG, JPG ou MP4 jusqu'à 50 Mo — recadrées automatiquement pour le fil et les stories.</p>
</>
)}
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button
onClick={() => cameraInputRef.current?.click()}
disabled={uploadMutation.isPending}
variant="outline"
className="lg:hidden"
data-testid="button-camera"
>
<Camera className="w-4 h-4" />
Caméra
</Button>
<Button onClick={open} data-testid="button-browse">
<Upload className="w-4 h-4" />
Importer
</Button>
</div>
</div>
<div className="p-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-6">
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-2xl p-12 text-center cursor-pointer transition-all
${isDragActive ? "border-primary bg-primary/5 shadow-lg" : "border-border/50 hover:border-primary hover:bg-accent/30"}
${uploadingCount > 0 ? "pointer-events-none opacity-50" : ""}
`}
data-testid="dropzone-upload"
>
<input {...getInputProps()} />
<input
ref={cameraInputRef}
type="file"
accept="image/*,video/*"
capture="environment"
onChange={handleCameraCapture}
className="hidden"
/>
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary/10 to-secondary/10 flex items-center justify-center mx-auto mb-6">
{uploadingCount > 0 ? (
<Loader2 className="w-8 h-8 text-primary animate-spin" />
) : (
<CloudUpload className="w-8 h-8 text-primary" />
)}
</div>
{uploadingCount > 0 ? (
<>
<p className="text-foreground font-semibold text-lg mb-2">
Téléchargement en cours...
</p>
<p className="text-sm text-muted-foreground mb-4">
{uploadingCount} fichier(s) en cours de traitement
</p>
</>
) : (
<>
<p className="text-foreground font-semibold text-lg mb-2">
{isDragActive ? "Déposez vos fichiers ici" : "Glissez-déposez vos fichiers"}
</p>
<p className="text-sm text-muted-foreground mb-4">ou cliquez pour parcourir</p>
<p className="text-xs text-muted-foreground font-medium">PNG, JPG, MP4 jusqu'à 50MB</p>
</>
)}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold">Vos médias</h2>
<span className="text-xs text-muted-foreground">{totalMedia} élément(s)</span>
</div>
<div className="space-y-4">
<h4 className="text-sm font-semibold text-foreground flex items-center justify-between">
<span>Fichiers téléchargés</span>
<span className="text-xs font-medium text-muted-foreground bg-muted/30 px-3 py-1 rounded-full">
{totalMedia} média(s)
</span>
</h4>
<div className="max-h-[500px] overflow-y-auto">
<div className="grid grid-cols-3 gap-3">
{mediaList.map((media: any) => (
<div
key={media.id}
onClick={() => setSelectedFile(media)}
className="relative aspect-square rounded-lg overflow-hidden border-2 border-border/50 cursor-pointer hover:border-primary hover:shadow-lg transition-all group"
data-testid={`media-item-${media.id}`}
>
<MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={media.type === 'video' ? 'video' : 'image'}
/>
<div className="absolute top-2 right-2 flex gap-2">
{media.type === "image" && (
<button
onClick={(e) => {
e.stopPropagation();
setZoomImage(media.originalUrl);
}}
className="w-8 h-8 bg-black/70 hover:bg-primary rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
data-testid={`button-zoom-${media.id}`}
>
<ZoomIn className="w-4 h-4 text-white" />
</button>
)}
{mediaList.length === 0 ? (
<EmptyState
icon={ImageIcon}
title="Votre médiathèque est vide"
description="Importez vos premières images ou vidéos pour les utiliser dans vos publications."
/>
) : (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 sm:gap-3 xl:grid-cols-5">
{mediaList.map((media: any) => {
const selected = selectedFile?.id === media.id;
return (
<div
key={media.id}
onClick={() => setSelectedFile(media)}
className={`group relative aspect-square cursor-pointer overflow-hidden rounded-lg border-2 transition-all ${selected ? "border-primary" : "border-transparent hover:border-primary/40"}`}
data-testid={`media-item-${media.id}`}
>
<MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={media.type === 'video' ? 'video' : 'image'}
/>
<div className="absolute right-1.5 top-1.5 flex gap-1.5 transition-opacity sm:opacity-0 sm:group-hover:opacity-100">
{media.type === "image" && (
<button
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate(media.id);
setZoomImage(media.originalUrl);
}}
className="w-8 h-8 bg-black/70 hover:bg-destructive rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
data-testid={`button-delete-${media.id}`}
className="flex h-7 w-7 items-center justify-center rounded-full bg-background/85 text-foreground shadow backdrop-blur hover:bg-background"
aria-label="Agrandir"
data-testid={`button-zoom-${media.id}`}
>
<X className="w-4 h-4 text-white" />
<ZoomIn className="h-3.5 w-3.5" />
</button>
</div>
)}
<button
onClick={(e) => {
e.stopPropagation();
setMediaToDelete(media);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-background/85 text-destructive shadow backdrop-blur hover:bg-destructive hover:text-destructive-foreground"
aria-label="Supprimer"
data-testid={`button-delete-${media.id}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
{/* Élément sentinelle pour le scroll infini */}
{hasNextPage ? (
<div ref={loadMoreRef} className="flex justify-center py-4 mt-3">
<Loader2 className="w-5 h-5 text-primary animate-spin" />
</div>
) : null}
</div>
);
})}
</div>
</div>
)}
<div className="space-y-6">
<h4 className="text-sm font-semibold text-foreground">Aperçu et recadrage automatique</h4>
<div className="space-y-5">
<div className="border border-border/50 rounded-xl p-5 bg-card shadow-sm">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<SiFacebook className="text-[#1877F2] text-lg" />
<SiInstagram className="text-[#E4405F] text-lg" />
<span className="text-sm font-semibold text-foreground">Feed</span>
</div>
<span className="text-xs text-muted-foreground bg-muted/30 px-2 py-1 rounded-md font-medium">1080×1080</span>
</div>
<div className="aspect-square bg-muted/30 rounded-lg overflow-hidden border border-border/50" style={{ width: '250px' }}>
{selectedFile?.facebookFeedUrl || selectedFile?.instagramFeedUrl ? (
<img
src={selectedFile.facebookFeedUrl || selectedFile.instagramFeedUrl}
alt="Feed preview"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageIcon className="w-12 h-12 text-muted-foreground opacity-50" />
</div>
)}
</div>
</div>
<div className="border border-border/50 rounded-xl p-5 bg-card shadow-sm">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<SiFacebook className="text-[#1877F2] text-lg" />
<SiInstagram className="text-[#E4405F] text-lg" />
<span className="text-sm font-semibold text-foreground">Story</span>
</div>
<span className="text-xs text-muted-foreground bg-muted/30 px-2 py-1 rounded-md font-medium">1080×1920</span>
</div>
<div className="aspect-[9/16] bg-muted/30 rounded-lg overflow-hidden border border-border/50" style={{ width: '200px' }}>
{selectedFile?.instagramStoryUrl ? (
<img
src={selectedFile.instagramStoryUrl}
alt="Story preview"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageIcon className="w-10 h-10 text-muted-foreground opacity-50" />
</div>
)}
</div>
</div>
{/* Élément sentinelle pour le scroll infini */}
{hasNextPage ? (
<div ref={loadMoreRef} className="mt-3 flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
</div>
) : null}
</div>
<aside>
<div className="space-y-3 rounded-xl border bg-card p-4 shadow-soft lg:sticky lg:top-6">
<div>
<h2 className="text-sm font-semibold">Formats générés</h2>
<p className="text-xs text-muted-foreground">
{selectedFile ? selectedFile.fileName : "Cliquez sur un média pour voir ses recadrages."}
</p>
</div>
{formatPreview("Fil", "1080×1080", selectedFile?.facebookFeedUrl || selectedFile?.instagramFeedUrl, "aspect-square", "w-full max-w-[260px]")}
{formatPreview("Story", "1080×1920", selectedFile?.instagramStoryUrl, "aspect-[9/16]", "w-full max-w-[160px]")}
</div>
</aside>
</div>
<AlertDialog open={!!mediaToDelete} onOpenChange={(o) => !o && setMediaToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer ce média ?</AlertDialogTitle>
<AlertDialogDescription>
« {mediaToDelete?.fileName} » sera définitivement retiré de la médiathèque.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (mediaToDelete) {
deleteMutation.mutate(mediaToDelete.id);
if (selectedFile?.id === mediaToDelete.id) setSelectedFile(null);
}
setMediaToDelete(null);
}}
>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={uploadMutation.isPending}>
<DialogContent className="sm:max-w-md [&>button]:hidden">
<div className="flex flex-col items-center justify-center py-8">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-6">
<Loader2 className="w-10 h-10 text-primary animate-spin" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">
Upload en cours...
<h3 className="text-lg font-semibold text-foreground mb-1">
Import en cours…
</h3>
<p className="text-sm text-muted-foreground text-center">
Votre image est en cours de téléchargement et de traitement.
<br />
Veuillez patienter.
Votre fichier est en cours d'envoi et de recadrage.
</p>
</div>
</DialogContent>
+23 -33
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ChevronLeft, ChevronRight, Send, Facebook, Instagram, Heart, MessageCircle, Share2, Bookmark, Loader2, Video } from 'lucide-react';
@@ -98,7 +99,7 @@ export function PreviewModal({
<div className="bg-white rounded-lg overflow-hidden border border-gray-200 max-w-lg mx-auto">
{/* Header */}
<div className="p-3 flex items-center gap-3 border-b border-gray-200">
<div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center">
<div className="w-10 h-10 rounded-full bg-facebook flex items-center justify-center">
<Facebook className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
@@ -404,7 +405,7 @@ export function PreviewModal({
<Instagram className="w-5 h-5 text-white" />
</div>
) : (
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center">
<div className="w-8 h-8 rounded-full bg-facebook flex items-center justify-center">
<Facebook className="w-5 h-5 text-white" />
</div>
)}
@@ -503,42 +504,31 @@ export function PreviewModal({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Prévisualisation de la publication</DialogTitle>
<DialogTitle>Aperçu de la publication</DialogTitle>
<DialogDescription>
Visualisez votre publication avant de la publier
Vérifiez le rendu sur chaque réseau avant de valider.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Format selector */}
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Format de prévisualisation:</label>
<Select value={previewFormat} onValueChange={(value: PreviewFormat) => {
setPreviewFormat(value);
<Tabs
value={previewFormat}
onValueChange={(value) => {
setPreviewFormat(value as PreviewFormat);
setCurrentPhotoIndex(0);
}}>
<SelectTrigger className="w-64" data-testid="select-preview-format">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="facebook-feed" data-testid="option-facebook-feed">
Feed Facebook (1200x630)
</SelectItem>
<SelectItem value="facebook-story" data-testid="option-facebook-story">
Story Facebook (1080x1920)
</SelectItem>
<SelectItem value="instagram-feed" data-testid="option-instagram-feed">
Feed Instagram (1080x1080)
</SelectItem>
<SelectItem value="instagram-story" data-testid="option-instagram-story">
Story Instagram (1080x1920)
</SelectItem>
</SelectContent>
</Select>
</div>
}}
>
<TabsList className="grid h-auto w-full grid-cols-2 sm:grid-cols-4" data-testid="select-preview-format">
<TabsTrigger value="facebook-feed" data-testid="option-facebook-feed">Facebook · Fil</TabsTrigger>
<TabsTrigger value="facebook-story" data-testid="option-facebook-story">Facebook · Story</TabsTrigger>
<TabsTrigger value="instagram-feed" data-testid="option-instagram-feed">Instagram · Fil</TabsTrigger>
<TabsTrigger value="instagram-story" data-testid="option-instagram-story">Instagram · Story</TabsTrigger>
</TabsList>
</Tabs>
{/* Preview */}
<div className="border rounded-lg p-6 bg-gray-50">
<div className="border rounded-lg p-4 sm:p-6 bg-muted/40">
{previewFormat === 'facebook-feed' && renderFacebookFeed()}
{previewFormat === 'facebook-story' && renderFacebookStory()}
{previewFormat === 'instagram-feed' && renderInstagramFeed()}
@@ -546,7 +536,7 @@ export function PreviewModal({
</div>
{/* Actions */}
<div className="flex justify-end gap-3">
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
{readOnly ? (
<Button
onClick={() => onOpenChange(false)}
@@ -561,16 +551,16 @@ export function PreviewModal({
onClick={() => onOpenChange(false)}
data-testid="button-cancel-preview"
>
Annuler
Modifier
</Button>
<Button
onClick={onPublish}
disabled={isPublishing}
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90"
variant="brand"
data-testid="button-publish-from-preview"
>
<Send className="w-4 h-4" />
{isPublishing ? 'Publication...' : 'Publier'}
{isPublishing ? 'Envoi…' : 'Confirmer'}
</Button>
</>
)}