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