mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
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:
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 & 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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user