mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 15:08:13 +02:00
Refonte UI : design system, logo, shell responsive et pages simples
- Nouveaux tokens (indigo/corail, statuts, plateformes) et mode sombre - Logo et favicon Social Flow redessinés - AppShell partagé : sidebar groupée (desktop) + barre de navigation basse (mobile) - Suppression des pages mobiles dupliquées et des composants morts - Tableau de bord, connexion, 404, calendrier, médiathèque, IA, statistiques Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ADkrVcVFTT14hbB148HYS
This commit is contained in:
55 files changed
+1591
-6331
No files matched your search
@@ -1,38 +0,0 @@
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { AnalyticsDashboard } from '@/components/analytics/AnalyticsDashboard';
|
||||
import { useState } from "react";
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
<div className="p-4 sm:p-6 lg:p-8 space-y-6 sm:space-y-8 max-w-[1600px] mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Analystique Sociale</h1>
|
||||
<p className="text-muted-foreground">Suivez vos performances Facebook & Instagram.</p>
|
||||
</div>
|
||||
<AnalyticsDashboard />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+11
-35
@@ -1,41 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Bot } from "lucide-react";
|
||||
import { Page } from "@/components/layout/app-shell";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import AiChat from "@/components/ai-chat";
|
||||
|
||||
export default function AI() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
<div className="p-4 sm:p-6 lg:p-8 max-w-5xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground">Assistant IA</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Générez du contenu personnalisé avec l'intelligence artificielle
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AiChat />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Page width="default">
|
||||
<PageHeader
|
||||
icon={Bot}
|
||||
title="Assistant IA"
|
||||
description="Discutez avec l'IA pour trouver des idées et rédiger vos textes."
|
||||
/>
|
||||
<AiChat />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { Page } from "@/components/layout/app-shell";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard";
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
return (
|
||||
<Page width="wide">
|
||||
<PageHeader
|
||||
icon={BarChart3}
|
||||
title="Statistiques"
|
||||
description="Suivez l'audience et l'engagement de vos comptes."
|
||||
/>
|
||||
<AnalyticsDashboard />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,26 @@
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Link } from "wouter";
|
||||
import { Calendar as CalendarIcon, Plus } from "lucide-react";
|
||||
import { Page } from "@/components/layout/app-shell";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import CalendarView from "@/components/calendar-view";
|
||||
|
||||
export default function Calendar() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
<div className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground">Calendrier</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Visualisez et gérez vos publications planifiées
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CalendarView />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Page width="wide">
|
||||
<PageHeader
|
||||
icon={CalendarIcon}
|
||||
title="Calendrier"
|
||||
description="Visualisez et gérez vos publications programmées."
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href="/new">
|
||||
<Plus className="h-4 w-4" /> Programmer
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CalendarView />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +1,56 @@
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Link } from "wouter";
|
||||
import { Film, PenSquare } from "lucide-react";
|
||||
import { Page } from "@/components/layout/app-shell";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import StatsCards from "@/components/stats-cards";
|
||||
import OngoingReels from "@/components/ongoing-reels";
|
||||
import RecentPublications from "@/components/recent-publications";
|
||||
import ManagedPages from "@/components/managed-pages";
|
||||
import { useState } from "react";
|
||||
import { useSession } from "@/hooks/use-session";
|
||||
|
||||
function greeting(): string {
|
||||
const h = new Date().getHours();
|
||||
if (h < 5 || h >= 18) return "Bonsoir";
|
||||
return "Bonjour";
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { session } = useSession();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<Page width="wide">
|
||||
<PageHeader
|
||||
title={`${greeting()}${session ? ` ${session.username}` : ""} 👋`}
|
||||
description="Voici un aperçu de votre activité sur les réseaux sociaux."
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="outline" className="flex-1 sm:flex-none">
|
||||
<Link href="/reel">
|
||||
<Film className="h-4 w-4" /> Nouveau Reel
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="brand" className="flex-1 sm:flex-none">
|
||||
<Link href="/new">
|
||||
<PenSquare className="h-4 w-4" /> Nouvelle publication
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={`
|
||||
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
<div className="p-4 sm:p-6 lg:p-8 space-y-6 sm:space-y-8 max-w-[1600px] mx-auto">
|
||||
<StatsCards />
|
||||
|
||||
<OngoingReels />
|
||||
|
||||
<RecentPublications />
|
||||
|
||||
<ManagedPages />
|
||||
<div className="space-y-6">
|
||||
<StatsCards />
|
||||
<OngoingReels />
|
||||
<div className="grid gap-6 xl:grid-cols-5">
|
||||
<div className="xl:col-span-3">
|
||||
<RecentPublications />
|
||||
</div>
|
||||
<div className="xl:col-span-2">
|
||||
<ManagedPages />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
+62
-43
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { Lock, User, Sparkles } from "lucide-react";
|
||||
import { AlertTriangle, CheckCircle2, Loader2, Lock, User } from "lucide-react";
|
||||
import { Logo, LogoMark } from "@/components/brand/logo";
|
||||
|
||||
export default function Login() {
|
||||
const [, setLocation] = useLocation();
|
||||
@@ -56,38 +56,59 @@ export default function Login() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-secondary/5 p-4">
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
<div className="flex min-h-screen bg-background">
|
||||
{/* Panneau de marque (desktop) */}
|
||||
<div className="relative hidden flex-1 overflow-hidden gradient-brand lg:flex lg:flex-col lg:justify-between lg:p-12">
|
||||
<div className="absolute -right-24 -top-24 h-96 w-96 rounded-full bg-white/10 blur-3xl" />
|
||||
<div className="absolute -bottom-32 -left-16 h-96 w-96 rounded-full bg-white/10 blur-3xl" />
|
||||
<div className="relative flex items-center gap-3 text-white">
|
||||
<LogoMark size={40} className="rounded-xl ring-1 ring-white/30" />
|
||||
<span className="text-xl font-semibold tracking-tight">Social Flow</span>
|
||||
</div>
|
||||
<div className="relative max-w-md text-white">
|
||||
<h2 className="text-4xl font-semibold leading-tight tracking-tight">
|
||||
Vos réseaux sociaux, en pilote automatique.
|
||||
</h2>
|
||||
<ul className="mt-8 space-y-3 text-white/90">
|
||||
{[
|
||||
"Créez publications et Reels en quelques clics",
|
||||
"Programmez sur Facebook, Instagram et TikTok",
|
||||
"Laissez l'IA rédiger vos textes",
|
||||
].map((t) => (
|
||||
<li key={t} className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0" />
|
||||
{t}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="relative text-sm text-white/70">© Social Flow</p>
|
||||
</div>
|
||||
|
||||
<Card className="w-full max-w-md rounded-2xl border-border/50 shadow-2xl relative z-10">
|
||||
<CardHeader className="space-y-4 text-center pt-8 pb-6">
|
||||
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary to-secondary mx-auto flex items-center justify-center shadow-2xl">
|
||||
<Sparkles className="w-10 h-10 text-white" />
|
||||
{/* Formulaire */}
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 lg:hidden">
|
||||
<Logo size={44} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-3xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
Social Flow
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base mt-3">
|
||||
Connectez-vous à votre compte pour gérer vos publications
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-8 px-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="username" className="text-sm font-semibold" data-testid="label-username">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Connexion</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Connectez-vous pour gérer vos publications.</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" data-testid="label-username">
|
||||
Nom d'utilisateur
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<User className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="admin"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="pl-12 h-12 rounded-xl bg-muted/30 border-border/50 focus:border-primary/50"
|
||||
className="h-11 pl-10"
|
||||
required
|
||||
disabled={isLoading}
|
||||
data-testid="input-username"
|
||||
@@ -95,19 +116,20 @@ export default function Login() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="password" className="text-sm font-semibold" data-testid="label-password">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" data-testid="label-password">
|
||||
Mot de passe
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-12 h-12 rounded-xl bg-muted/30 border-border/50 focus:border-primary/50"
|
||||
className="h-11 pl-10"
|
||||
required
|
||||
disabled={isLoading}
|
||||
data-testid="input-password"
|
||||
@@ -115,28 +137,25 @@ export default function Login() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 rounded-xl bg-gradient-to-r from-primary to-secondary hover:opacity-90 text-base font-semibold shadow-lg mt-6"
|
||||
disabled={isLoading}
|
||||
data-testid="button-login"
|
||||
>
|
||||
{isLoading ? "Connexion en cours..." : "Se connecter"}
|
||||
<Button type="submit" variant="brand" size="lg" className="w-full" disabled={isLoading} data-testid="button-login">
|
||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{isLoading ? "Connexion en cours…" : "Se connecter"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{showDefaultPassword && (
|
||||
<div className="mt-8 text-center p-4 rounded-xl bg-warning/10 border border-warning/20">
|
||||
<p className="text-sm text-muted-foreground mb-2">Identifiants par défaut :</p>
|
||||
<p className="font-semibold text-foreground">admin / admin</p>
|
||||
<p className="text-xs mt-3 text-warning flex items-center justify-center gap-2">
|
||||
<span className="text-base">⚠️</span>
|
||||
Changez ce mot de passe après la première connexion
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3 rounded-xl border border-warning/30 bg-warning/10 p-4 text-sm">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<div>
|
||||
<p className="text-foreground">
|
||||
Identifiants par défaut : <span className="font-semibold">admin / admin</span>
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">Changez ce mot de passe après la première connexion.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+11
-35
@@ -1,41 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Images } from "lucide-react";
|
||||
import { Page } from "@/components/layout/app-shell";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import MediaUpload from "@/components/media-upload";
|
||||
|
||||
export default function Media() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
<div className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground">Médiathèque</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gérez vos images et vidéos pour vos publications
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MediaUpload />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Page width="wide">
|
||||
<PageHeader
|
||||
icon={Images}
|
||||
title="Médiathèque"
|
||||
description="Toutes vos images et vidéos, prêtes à être publiées."
|
||||
/>
|
||||
<MediaUpload />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import AiChat from "@/components/ai-chat";
|
||||
|
||||
export default function AIMobile() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
{/* MOBILE: Compact layout */}
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-foreground">Assistant IA</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Générez du contenu personnalisé avec l'intelligence artificielle
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AiChat />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { AnalyticsDashboard } from '@/components/analytics/AnalyticsDashboard';
|
||||
import { useState } from "react";
|
||||
|
||||
export default function AnalyticsPageMobile() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
<div className="p-4 space-y-6">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight mb-2">Analystique Sociale</h1>
|
||||
<p className="text-sm text-muted-foreground">Suivez vos performances.</p>
|
||||
</div>
|
||||
<AnalyticsDashboard />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
import AudioAdmin from "../audio-admin";
|
||||
export default AudioAdmin;
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import CalendarView from "@/components/calendar-view";
|
||||
|
||||
export default function CalendarMobile() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
{/* MOBILE: Compact layout with reduced padding */}
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-foreground">Calendrier</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Visualisez et gérez vos publications planifiées
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CalendarView />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import TopBar from "@/components/topbar";
|
||||
import OngoingReels from "@/components/ongoing-reels";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import {
|
||||
PlusCircle,
|
||||
Video,
|
||||
Calendar,
|
||||
BarChart3,
|
||||
Images,
|
||||
Wand2,
|
||||
Clock,
|
||||
Settings,
|
||||
Users,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
CalendarClock
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function DashboardMobile() {
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
const mainActions = [
|
||||
{ icon: PlusCircle, label: "Nv. Post", href: "/new", color: "text-blue-500", bg: "bg-blue-500/10", border: "border-blue-500/20", description: "Créer une publication" },
|
||||
{ icon: Video, label: "Nv. Reel", href: "/reel", color: "text-purple-500", bg: "bg-purple-500/10", border: "border-purple-500/20", description: "Générer une vidéo IA" },
|
||||
{ icon: Calendar, label: "Calendrier", href: "/calendar", color: "text-amber-500", bg: "bg-amber-500/10", border: "border-amber-500/20", description: "Voir le planning" },
|
||||
{ icon: BarChart3, label: "Analytics", href: "/analytics", color: "text-emerald-500", bg: "bg-emerald-500/10", border: "border-emerald-500/20", description: "Statistiques" },
|
||||
];
|
||||
|
||||
const secondaryMenu = [
|
||||
{ icon: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule" },
|
||||
{ icon: Images, label: "Médiathèque", href: "/media" },
|
||||
{ icon: Wand2, label: "Éditeur d'images", href: "/image-editor" },
|
||||
{ icon: Clock, label: "Historique", href: "/history" },
|
||||
{ icon: Users, label: "Pages connectées", href: "/pages" },
|
||||
{ icon: Settings, label: "Paramètres", href: "/settings" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => { }} hideMenuButton={true} />
|
||||
|
||||
<div className="p-4 space-y-6 pb-20">
|
||||
|
||||
{/* Header Section */}
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg">
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
Social Flow
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">Tableau de bord</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1. Status Section */}
|
||||
<OngoingReels compact />
|
||||
|
||||
{/* 2. Main Action Grid */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg overflow-hidden">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-base font-semibold">Actions Rapides</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{mainActions.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<div className={`
|
||||
flex flex-col items-center justify-center p-4 rounded-xl border cursor-pointer transition-all active:scale-95 hover:opacity-80
|
||||
${item.bg} ${item.border}
|
||||
`}>
|
||||
<div className={`p-2 rounded-full bg-background/80 shadow-sm mb-2`}>
|
||||
<Icon className={`w-6 h-6 ${item.color}`} />
|
||||
</div>
|
||||
<span className="font-semibold text-sm text-foreground">{item.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground mt-1 text-center">{item.description}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. Secondary Menu List */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg overflow-hidden">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-base font-semibold">Menu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border/50">
|
||||
{secondaryMenu.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<div className="flex items-center gap-4 p-4 hover:bg-muted/50 transition-colors cursor-pointer active:bg-muted">
|
||||
<div className="p-2 rounded-lg bg-muted text-foreground">
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="flex-1 font-medium text-sm">{item.label}</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import {
|
||||
Calendar, CheckCircle, XCircle, Clock,
|
||||
MoreHorizontal, Filter, Search, Loader2
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
/** Post dont le statut peut changer d'un instant à l'autre. */
|
||||
function isChanging(post: any): boolean {
|
||||
if (post?.generationStatus === 'pending' || post?.generationStatus === 'processing') {
|
||||
return true;
|
||||
}
|
||||
return post?.status === 'scheduled' && !!post?.scheduledFor && new Date(post.scheduledFor) <= new Date();
|
||||
}
|
||||
|
||||
export default function MobileHistory() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// Fetch posts history with polling to see status updates
|
||||
const { data: posts } = useQuery<any[]>({
|
||||
queryKey: ['/api/posts'],
|
||||
// Un post planifié pour la semaine prochaine ne bougera pas d'ici une
|
||||
// seconde : on ne suit de près que ce dont le statut peut basculer
|
||||
// maintenant (génération en cours, ou heure de publication dépassée).
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some(isChanging) ? 3000 : 30000,
|
||||
});
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published': return <CheckCircle className="w-5 h-5 text-green-500" />;
|
||||
case 'failed': return <XCircle className="w-5 h-5 text-red-500" />;
|
||||
case 'scheduled': return <Clock className="w-5 h-5 text-amber-500" />;
|
||||
case 'draft': return <Loader2 className="w-5 h-5 text-blue-500 animate-spin" />;
|
||||
default: return <Clock className="w-5 h-5 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published': return "Publié";
|
||||
case 'failed': return "Échec";
|
||||
case 'scheduled': return "Programmé";
|
||||
case 'draft': return "Traitement...";
|
||||
default: return "Brouillon";
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPosts = posts?.filter(post =>
|
||||
post.content?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) || [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Mobile Header */}
|
||||
<div className="sticky top-0 z-50 bg-background/95 backdrop-blur border-b p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="-ml-2">
|
||||
<MoreHorizontal className="h-6 w-6" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="p-0 w-[80%]">
|
||||
<Sidebar />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<h1 className="font-semibold text-lg">Historique</h1>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Filter className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Rechercher une publication..."
|
||||
className="pl-9 bg-secondary/50"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Timeline List */}
|
||||
<div className="space-y-3">
|
||||
{filteredPosts.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Clock className="w-12 h-12 mx-auto mb-3 opacity-20" />
|
||||
<p>Aucune publication trouvée</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredPosts.map((post) => (
|
||||
<Card key={post.id} className="overflow-hidden border-none shadow-sm bg-card">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-stretch">
|
||||
<div className={`w-1.5 ${post.status === 'published' ? 'bg-green-500' :
|
||||
post.status === 'failed' ? 'bg-red-500' :
|
||||
post.status === 'draft' ? 'bg-blue-500' : 'bg-amber-500'
|
||||
}`} />
|
||||
|
||||
<div className="flex-1 p-3">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<Badge variant="outline" className="flex items-center gap-1.5 px-2 py-0.5 h-6 text-xs font-normal">
|
||||
{getStatusIcon(post.status)}
|
||||
{getStatusLabel(post.status)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{post.scheduledFor ? format(new Date(post.scheduledFor), "d MMM HH:mm", { locale: fr }) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm line-clamp-2 text-foreground mb-2">
|
||||
{post.content || "Sans contenu"}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex -space-x-2">
|
||||
{/* Placeholder for page icons if available in data */}
|
||||
<div className="w-6 h-6 rounded-full bg-blue-100 border border-background flex items-center justify-center text-[10px]">fb</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>Voir détails</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-500">Supprimer</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,645 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Wand2, Save, Image as ImageIcon, X } from "lucide-react";
|
||||
import { MediaThumbnail } from "@/components/media-thumbnail";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface Ribbon {
|
||||
enabled: boolean;
|
||||
text: "Promo" | "Nouveauté" | "Arrivage" | "Stock limité";
|
||||
color: "red" | "yellow";
|
||||
position: "north_west" | "north_east";
|
||||
}
|
||||
|
||||
interface PriceBadge {
|
||||
enabled: boolean;
|
||||
price: string;
|
||||
color: "red" | "yellow";
|
||||
position: "north_east" | "south_east" | "north_west" | "south_west";
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Logo {
|
||||
enabled: boolean;
|
||||
opacity: number;
|
||||
position: "north_east" | "south_east" | "north_west" | "south_west" | "center";
|
||||
size: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
export default function ImageEditorMobile() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [selectedMedia, setSelectedMedia] = useState<any>(null);
|
||||
|
||||
const [ribbon, setRibbon] = useState<Ribbon>({
|
||||
enabled: false,
|
||||
text: "Promo",
|
||||
color: "red",
|
||||
position: "north_west"
|
||||
});
|
||||
|
||||
const [priceBadge, setPriceBadge] = useState<PriceBadge>({
|
||||
enabled: false,
|
||||
price: "",
|
||||
color: "red",
|
||||
position: "north_east",
|
||||
size: 20
|
||||
});
|
||||
|
||||
const [logo, setLogo] = useState<Logo>({
|
||||
enabled: false,
|
||||
opacity: 60,
|
||||
position: "south_east",
|
||||
size: "medium"
|
||||
});
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState<string>("");
|
||||
|
||||
const { data: mediaList } = useQuery({
|
||||
queryKey: ["/api/media"],
|
||||
});
|
||||
|
||||
const { data: cloudinaryConfig } = useQuery({
|
||||
queryKey: ['/api/cloudinary/config'],
|
||||
});
|
||||
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const saveImageMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedMedia) {
|
||||
throw new Error("Aucune image sélectionnée");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
imageUrl: selectedMedia.originalUrl,
|
||||
ribbon: ribbon.enabled ? ribbon : undefined,
|
||||
priceBadge: priceBadge.enabled && priceBadge.price ? priceBadge : undefined,
|
||||
logo: logo.enabled ? logo : undefined,
|
||||
};
|
||||
|
||||
const response = await apiRequest('POST', '/api/media/apply-overlays', payload);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
|
||||
toast({
|
||||
title: "Image enregistrée !",
|
||||
description: "L'image éditée a été ajoutée à votre médiathèque.",
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Impossible d'enregistrer l'image.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMedia) {
|
||||
setPreviewUrl("");
|
||||
return;
|
||||
}
|
||||
|
||||
let url = selectedMedia.originalUrl;
|
||||
|
||||
const cloudinaryPattern = /https:\/\/res\.cloudinary\.com\/([^\/]+)\/image\/upload\//;
|
||||
const match = url.match(cloudinaryPattern);
|
||||
|
||||
if (!match) {
|
||||
setPreviewUrl(url);
|
||||
return;
|
||||
}
|
||||
|
||||
const [fullMatch, cloudName] = match;
|
||||
const imagePath = url.replace(fullMatch, '');
|
||||
|
||||
const transformations: string[] = [];
|
||||
transformations.push('w_800,h_800,c_fit');
|
||||
|
||||
const transformedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/${transformations.join('/')}/${imagePath}`;
|
||||
setPreviewUrl(transformedUrl);
|
||||
|
||||
}, [selectedMedia, ribbon, priceBadge, logo]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<style>{`
|
||||
.ribbon-container {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ribbon-container.north_west {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.ribbon-container.north_east {
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.ribbon-triangle {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.ribbon-container.north_west .ribbon-triangle {
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-width: 140px 140px 0 0;
|
||||
}
|
||||
|
||||
.ribbon-container.north_west .ribbon-triangle.red {
|
||||
border-top-color: #FF0000;
|
||||
border-right-color: transparent;
|
||||
border-bottom-color: transparent;
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
.ribbon-container.north_west .ribbon-triangle.yellow {
|
||||
border-top-color: #FFC107;
|
||||
border-right-color: transparent;
|
||||
border-bottom-color: transparent;
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
.ribbon-container.north_east .ribbon-triangle {
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-width: 0 140px 140px 0;
|
||||
}
|
||||
|
||||
.ribbon-container.north_east .ribbon-triangle.red {
|
||||
border-top-color: transparent;
|
||||
border-right-color: #FF0000;
|
||||
border-bottom-color: transparent;
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
.ribbon-container.north_east .ribbon-triangle.yellow {
|
||||
border-top-color: transparent;
|
||||
border-right-color: #FFC107;
|
||||
border-bottom-color: transparent;
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
.ribbon-text {
|
||||
position: absolute;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
z-index: 11;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ribbon-container.north_west .ribbon-text {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-45deg) translateY(-20px);
|
||||
}
|
||||
|
||||
.ribbon-container.north_east .ribbon-text {
|
||||
top: 50%;
|
||||
right: 50%;
|
||||
transform: translate(50%, -50%) rotate(45deg) translateY(-20px);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
{/* MOBILE: Single column layout */}
|
||||
<div className="p-4 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<Wand2 className="w-7 h-7" />
|
||||
Éditeur d'images
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ajoutez des rubans, badges prix et filtres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preview Card - Top on mobile */}
|
||||
{selectedMedia && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="text-base">Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg overflow-hidden bg-muted aspect-square flex items-center justify-center relative">
|
||||
<div ref={previewRef} className="relative max-w-full max-h-full">
|
||||
<img
|
||||
src={previewUrl || selectedMedia.originalUrl}
|
||||
alt="Aperçu"
|
||||
className="max-w-full max-h-[400px] w-auto h-auto"
|
||||
data-testid="preview-image"
|
||||
/>
|
||||
|
||||
{ribbon.enabled && ribbon.text && (
|
||||
<div className={`ribbon-container ${ribbon.position}`}>
|
||||
<div className={`ribbon-triangle ${ribbon.color}`} />
|
||||
<div
|
||||
className="ribbon-text"
|
||||
style={{
|
||||
fontSize: ribbon.text === "Stock limité" ? '18px' :
|
||||
ribbon.text.length <= 5 ? '24px' :
|
||||
ribbon.text.length <= 8 ? '20px' :
|
||||
ribbon.text.length <= 11 ? '18px' : '14px',
|
||||
lineHeight: ribbon.text === "Stock limité" ? '1.2' : 'normal',
|
||||
whiteSpace: ribbon.text === "Stock limité" ? 'normal' : 'nowrap',
|
||||
transform: ribbon.text === "Stock limité"
|
||||
? (ribbon.position === "north_west"
|
||||
? 'translate(-50%, -50%) rotate(-45deg) translateY(-30px) translateX(2px)'
|
||||
: 'translate(50%, -50%) rotate(45deg) translateY(-30px) translateX(-2px)')
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
{ribbon.text === "Stock limité" ? (
|
||||
<>
|
||||
Stock
|
||||
<br />
|
||||
limité
|
||||
</>
|
||||
) : ribbon.text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{priceBadge.enabled && priceBadge.price && (
|
||||
<div
|
||||
data-badge-price="true"
|
||||
className={`absolute ${
|
||||
priceBadge.position === "north_east" ? "top-3 right-3" :
|
||||
priceBadge.position === "south_east" ? "bottom-3 right-3" :
|
||||
priceBadge.position === "south_west" ? "bottom-3 left-3" :
|
||||
"top-3 left-3"
|
||||
} px-4 py-2 rounded-full text-white font-bold z-10 flex items-center justify-center ${
|
||||
priceBadge.color === "red" ? "bg-red-600" : "bg-yellow-500"
|
||||
}`}
|
||||
style={{ fontSize: `${priceBadge.size}px` }}
|
||||
>
|
||||
{priceBadge.price} €
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logo.enabled && cloudinaryConfig && (cloudinaryConfig as any).logoPublicId && (
|
||||
<img
|
||||
src={`https://res.cloudinary.com/${(cloudinaryConfig as any).cloudName}/image/upload/${(cloudinaryConfig as any).logoPublicId}`}
|
||||
alt="Logo"
|
||||
className={`absolute ${
|
||||
logo.position === "north_east" ? "top-3 right-3" :
|
||||
logo.position === "south_east" ? "bottom-3 right-3" :
|
||||
logo.position === "south_west" ? "bottom-3 left-3" :
|
||||
logo.position === "center" ? "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" :
|
||||
"top-3 left-3"
|
||||
} z-10 max-w-full max-h-full object-contain`}
|
||||
style={{
|
||||
opacity: logo.opacity / 100,
|
||||
width: logo.size === "small" ? "30%" : logo.size === "large" ? "60%" : "45%"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedMedia(null)}
|
||||
className="absolute top-2 right-2 p-2 bg-black/70 hover:bg-black/90 rounded-full text-white transition-colors z-20 min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
data-testid="button-clear-selection"
|
||||
title="Désélectionner l'image"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full min-h-[48px]"
|
||||
variant="default"
|
||||
data-testid="button-save"
|
||||
onClick={() => saveImageMutation.mutate()}
|
||||
disabled={saveImageMutation.isPending}
|
||||
>
|
||||
<Save className="w-5 h-5 mr-2" />
|
||||
{saveImageMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Image Selection */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
Sélectionner une image
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mediaList && (mediaList as any[]).filter((m: any) => m.type === 'image').length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3 max-h-[300px] overflow-y-auto">
|
||||
{(mediaList as any[])
|
||||
.filter((m: any) => m.type === 'image')
|
||||
.map((media: any) => (
|
||||
<div
|
||||
key={media.id}
|
||||
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all min-h-[44px] ${
|
||||
selectedMedia?.id === media.id ? 'border-primary ring-2 ring-primary' : 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
onClick={() => setSelectedMedia(media)}
|
||||
data-testid={`select-image-${media.id}`}
|
||||
>
|
||||
<MediaThumbnail
|
||||
src={media.instagramFeedUrl || media.originalUrl}
|
||||
alt=""
|
||||
type="image"
|
||||
className="w-full h-28 object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
Aucune image disponible. Uploadez des images dans la médiathèque.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Ribbon Controls */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="text-base">Ruban promotionnel</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
|
||||
<Checkbox
|
||||
id="ribbon-enabled"
|
||||
checked={ribbon.enabled}
|
||||
onCheckedChange={(checked) => setRibbon({ ...ribbon, enabled: checked as boolean })}
|
||||
className="w-5 h-5"
|
||||
data-testid="checkbox-ribbon"
|
||||
/>
|
||||
<Label htmlFor="ribbon-enabled" className="cursor-pointer flex-1 text-sm">Afficher un ruban</Label>
|
||||
</div>
|
||||
|
||||
{ribbon.enabled && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm">Texte du ruban</Label>
|
||||
<RadioGroup
|
||||
value={ribbon.text}
|
||||
onValueChange={(value) => setRibbon({ ...ribbon, text: value as any })}
|
||||
className="space-y-2"
|
||||
>
|
||||
{["Promo", "Nouveauté", "Arrivage", "Stock limité"].map((text) => (
|
||||
<div key={text} className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
|
||||
<RadioGroupItem value={text} id={`ribbon-${text.toLowerCase()}`} data-testid={`radio-ribbon-${text.toLowerCase()}`} />
|
||||
<Label htmlFor={`ribbon-${text.toLowerCase()}`} className="cursor-pointer flex-1 text-sm">{text}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm">Couleur</Label>
|
||||
<RadioGroup
|
||||
value={ribbon.color}
|
||||
onValueChange={(value) => setRibbon({ ...ribbon, color: value as any })}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
|
||||
<RadioGroupItem value="red" id="ribbon-red" data-testid="radio-ribbon-red" />
|
||||
<Label htmlFor="ribbon-red" className="flex items-center gap-2 cursor-pointer flex-1 text-sm">
|
||||
Rouge <Badge className="bg-red-600">Exemple</Badge>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
|
||||
<RadioGroupItem value="yellow" id="ribbon-yellow" data-testid="radio-ribbon-yellow" />
|
||||
<Label htmlFor="ribbon-yellow" className="flex items-center gap-2 cursor-pointer flex-1 text-sm">
|
||||
Jaune <Badge className="bg-yellow-500">Exemple</Badge>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Position</Label>
|
||||
<Select value={ribbon.position} onValueChange={(value) => setRibbon({ ...ribbon, position: value as any })}>
|
||||
<SelectTrigger className="min-h-[48px]" data-testid="select-ribbon-position">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="north_west">Haut gauche</SelectItem>
|
||||
<SelectItem value="north_east">Haut droite</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Price Badge */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="text-base">Badge prix</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
|
||||
<Checkbox
|
||||
id="price-enabled"
|
||||
checked={priceBadge.enabled}
|
||||
onCheckedChange={(checked) => setPriceBadge({ ...priceBadge, enabled: checked as boolean })}
|
||||
className="w-5 h-5"
|
||||
data-testid="checkbox-price"
|
||||
/>
|
||||
<Label htmlFor="price-enabled" className="cursor-pointer flex-1 text-sm">Afficher le prix</Label>
|
||||
</div>
|
||||
|
||||
{priceBadge.enabled && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="price-input" className="text-sm">Prix (€)</Label>
|
||||
<Input
|
||||
id="price-input"
|
||||
type="text"
|
||||
placeholder="19.99"
|
||||
value={priceBadge.price}
|
||||
onChange={(e) => setPriceBadge({ ...priceBadge, price: e.target.value })}
|
||||
className="min-h-[48px]"
|
||||
data-testid="input-price"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm">Couleur</Label>
|
||||
<RadioGroup
|
||||
value={priceBadge.color}
|
||||
onValueChange={(value) => setPriceBadge({ ...priceBadge, color: value as any })}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
|
||||
<RadioGroupItem value="red" id="price-red" data-testid="radio-price-red" />
|
||||
<Label htmlFor="price-red" className="flex items-center gap-2 cursor-pointer flex-1 text-sm">
|
||||
Rouge <Badge className="bg-red-600">19.99€</Badge>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
|
||||
<RadioGroupItem value="yellow" id="price-yellow" data-testid="radio-price-yellow" />
|
||||
<Label htmlFor="price-yellow" className="flex items-center gap-2 cursor-pointer flex-1 text-sm">
|
||||
Jaune <Badge className="bg-yellow-500">19.99€</Badge>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Position</Label>
|
||||
<Select value={priceBadge.position} onValueChange={(value) => setPriceBadge({ ...priceBadge, position: value as any })}>
|
||||
<SelectTrigger className="min-h-[48px]" data-testid="select-price-position">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="north_east">Haut droite</SelectItem>
|
||||
<SelectItem value="north_west">Haut gauche</SelectItem>
|
||||
<SelectItem value="south_east">Bas droite</SelectItem>
|
||||
<SelectItem value="south_west">Bas gauche</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-sm">Taille du texte</Label>
|
||||
<span className="text-sm text-muted-foreground">{priceBadge.size}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[priceBadge.size]}
|
||||
onValueChange={(value) => setPriceBadge({ ...priceBadge, size: value[0] })}
|
||||
min={12}
|
||||
max={70}
|
||||
step={1}
|
||||
className="touch-manipulation"
|
||||
data-testid="slider-price-size"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Logo */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="text-base">Logo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
|
||||
<Checkbox
|
||||
id="logo-enabled"
|
||||
checked={logo.enabled}
|
||||
onCheckedChange={(checked) => setLogo({ ...logo, enabled: checked as boolean })}
|
||||
className="w-5 h-5"
|
||||
data-testid="checkbox-logo-enabled"
|
||||
/>
|
||||
<Label htmlFor="logo-enabled" className="cursor-pointer flex-1 text-sm">
|
||||
Afficher le logo
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{logo.enabled && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Position</Label>
|
||||
<Select value={logo.position} onValueChange={(value) => setLogo({ ...logo, position: value as any })}>
|
||||
<SelectTrigger className="min-h-[48px]" data-testid="select-logo-position">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="north_west">Haut gauche</SelectItem>
|
||||
<SelectItem value="north_east">Haut droit</SelectItem>
|
||||
<SelectItem value="south_west">Bas gauche</SelectItem>
|
||||
<SelectItem value="south_east">Bas droit</SelectItem>
|
||||
<SelectItem value="center">Centre</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Taille</Label>
|
||||
<Select value={logo.size} onValueChange={(value) => setLogo({ ...logo, size: value as any })}>
|
||||
<SelectTrigger className="min-h-[48px]" data-testid="select-logo-size">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="small">Petit</SelectItem>
|
||||
<SelectItem value="medium">Moyen</SelectItem>
|
||||
<SelectItem value="large">Grand</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-sm">Opacité</Label>
|
||||
<span className="text-sm text-muted-foreground">{logo.opacity}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[logo.opacity]}
|
||||
onValueChange={(value) => setLogo({ ...logo, opacity: value[0] })}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
className="touch-manipulation"
|
||||
data-testid="slider-logo-opacity"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import MediaUpload from "@/components/media-upload";
|
||||
|
||||
export default function MediaMobile() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
{/* MOBILE: Reduced padding for better space usage */}
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-foreground">Médiathèque</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gérez vos images et vidéos pour vos publications
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MediaUpload />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,800 +0,0 @@
|
||||
import { useState, useCallback, useRef, useMemo } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useLocation } from "wouter";
|
||||
import { Send, Sparkles, Image as ImageIcon, Calendar, Upload, Camera, GripVertical, Loader2 } from "lucide-react";
|
||||
import { MediaThumbnail } from "@/components/media-thumbnail";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
rectSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
|
||||
import { PreviewModal } from "@/components/preview-modal";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
|
||||
function SortableMediaItem({
|
||||
media,
|
||||
index,
|
||||
isSelected,
|
||||
onToggle
|
||||
}: {
|
||||
media: Media;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id: media.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const isVideo = media.type === 'video';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-primary ring-2 ring-primary'
|
||||
: 'border-transparent hover:border-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full h-full min-h-[44px]"
|
||||
data-testid={`button-select-media-${media.id}`}
|
||||
>
|
||||
<MediaThumbnail
|
||||
src={media.facebookFeedUrl || media.originalUrl}
|
||||
alt={media.fileName}
|
||||
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||
type={isVideo ? 'video' : 'image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
{isSelected && (
|
||||
<>
|
||||
<div className="absolute top-2 right-2 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-bold shadow-lg">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="absolute top-2 left-2 w-10 h-10 bg-black/70 rounded-full flex items-center justify-center cursor-move hover:bg-black/90 transition-colors touch-manipulation"
|
||||
>
|
||||
<GripVertical className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nombre de médias récents proposés à la sélection. */
|
||||
const RECENT_MEDIA_COUNT = 12;
|
||||
|
||||
/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */
|
||||
function startOfToday(): Date {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
export default function NewPostMobile() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [, navigate] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [productInfo, setProductInfo] = useState('');
|
||||
const [selectedMedia, setSelectedMedia] = useState<string[]>([]);
|
||||
const [recentUploads, setRecentUploads] = useState<Media[]>([]);
|
||||
const [selectedPages, setSelectedPages] = useState<string[]>([]);
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [postText, setPostText] = useState('');
|
||||
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||
|
||||
// Ce composeur gère les publications feed/story : TikTok n'accepte que des
|
||||
// vidéos et se pilote depuis la création de reel.
|
||||
const { data: allPages = [] } = useQuery<SocialPage[]>({
|
||||
queryKey: ['/api/pages'],
|
||||
});
|
||||
const pages = allPages.filter(p => p.platform !== 'tiktok');
|
||||
|
||||
// Seuls les douze derniers médias sont proposés ici : autant ne demander
|
||||
// que ceux-là plutôt que toute la médiathèque.
|
||||
const { data: allMedia = [] } = useQuery<Media[]>({
|
||||
queryKey: ['/api/media', { limit: RECENT_MEDIA_COUNT }],
|
||||
});
|
||||
|
||||
const mediaList = useMemo(() => {
|
||||
const combined = [
|
||||
...recentUploads,
|
||||
...allMedia.filter(m => !recentUploads.some(r => r.id === m.id)),
|
||||
];
|
||||
return combined
|
||||
.sort((a, b) => {
|
||||
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
})
|
||||
.slice(0, RECENT_MEDIA_COUNT);
|
||||
}, [allMedia, recentUploads]);
|
||||
|
||||
// Ne sert qu'à griser les créneaux déjà pris dans le sélecteur de date : le
|
||||
// passé n'a aucune influence sur une planification à venir.
|
||||
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
||||
queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }],
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await fetch("/api/media/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) handleUnauthorized("/api/media/upload");
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (data: Media) => {
|
||||
setRecentUploads(prev => [data, ...prev.filter(m => m.id !== data.id)]);
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
|
||||
setSelectedMedia(prev => {
|
||||
if (prev.length >= 10) {
|
||||
toast({
|
||||
title: "Limite atteinte",
|
||||
description: "Maximum 10 photos par publication",
|
||||
variant: "destructive",
|
||||
});
|
||||
return prev;
|
||||
}
|
||||
return [...prev, data.id];
|
||||
});
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Photo téléchargée avec succès",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de télécharger la photo",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
uploadMutation.mutate(acceptedFiles[0]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"],
|
||||
"video/*": [".mp4", ".mov", ".webm", ".3gp", ".3gpp", ".mkv", ".avi"],
|
||||
},
|
||||
maxSize: 52428800,
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
});
|
||||
|
||||
const generateTextMutation = useMutation({
|
||||
mutationFn: async (productInfoText: string) => {
|
||||
const productInfo = {
|
||||
name: productInfoText.match(/Produit:\s*(.+?)(?:\n|$)/i)?.[1] ||
|
||||
productInfoText.match(/Nom:\s*(.+?)(?:\n|$)/i)?.[1] ||
|
||||
productInfoText,
|
||||
price: productInfoText.match(/Prix:\s*(.+?)(?:\n|$)/i)?.[1] || "",
|
||||
description: productInfoText.match(/Description:\s*(.+?)(?:\n|$)/i)?.[1] || "",
|
||||
features: productInfoText.match(/Caractéristiques:\s*(.+?)(?:\n|$)/i)?.[1]?.split(",") || [],
|
||||
};
|
||||
const response = await apiRequest('POST', '/api/ai/generate', productInfo);
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
const variants = data.variants || [];
|
||||
setGeneratedVariants(variants);
|
||||
toast({
|
||||
title: "Texte généré",
|
||||
description: `${variants.length} variations créées`,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: getErrorMessage(error, "Impossible de générer le texte"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const createPostMutation = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
apiRequest('POST', '/api/posts', data),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
||||
toast({
|
||||
title: "Publication créée",
|
||||
description: "La publication a été créée avec succès",
|
||||
});
|
||||
navigate('/');
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de créer la publication",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleGenerateText = () => {
|
||||
if (!productInfo.trim()) {
|
||||
toast({
|
||||
title: "Information manquante",
|
||||
description: "Veuillez saisir les informations du produit",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
generateTextMutation.mutate(productInfo);
|
||||
};
|
||||
|
||||
const handleCreatePost = () => {
|
||||
if (!postText.trim()) {
|
||||
toast({
|
||||
title: "Contenu requis",
|
||||
description: "Veuillez saisir le contenu de la publication",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPages.length === 0) {
|
||||
toast({
|
||||
title: "Page requise",
|
||||
description: "Veuillez sélectionner au moins une page",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ((postType === 'story' || postType === 'both') && selectedMedia.length === 0) {
|
||||
toast({
|
||||
title: "Média requis",
|
||||
description: "Les stories nécessitent au moins une image ou vidéo",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createPostMutation.mutate({
|
||||
content: postText,
|
||||
scheduledFor: scheduledDate ? scheduledDate.toISOString() : undefined,
|
||||
mediaIds: selectedMedia.length > 0 ? selectedMedia : undefined,
|
||||
pageIds: selectedPages,
|
||||
postType,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUseVariant = (text: string) => {
|
||||
setPostText(text);
|
||||
toast({
|
||||
title: "Texte sélectionné",
|
||||
description: "Le texte a été ajouté à votre publication",
|
||||
});
|
||||
};
|
||||
|
||||
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
let fileToUpload = file;
|
||||
if (!file.name || file.name === 'blob' || file.name === '') {
|
||||
const extension = file.type.split('/')[1] || 'jpg';
|
||||
const newFileName = `camera-${Date.now()}.${extension}`;
|
||||
fileToUpload = new File([file], newFileName, { type: file.type });
|
||||
}
|
||||
|
||||
if (file.size > 52428800) {
|
||||
toast({
|
||||
title: "Fichier trop volumineux",
|
||||
description: `La taille maximale est de 50 MB. Votre fichier fait ${(file.size / 1024 / 1024).toFixed(2)} MB`,
|
||||
variant: "destructive",
|
||||
});
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
uploadMutation.mutate(fileToUpload);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setSelectedMedia((items) => {
|
||||
const oldIndex = items.indexOf(active.id as string);
|
||||
const newIndex = items.indexOf(over.id as string);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
<div className="p-4 space-y-6 pb-24">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-foreground">Nouvelle publication</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Créez et planifiez une nouvelle publication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* MOBILE: Single column layout */}
|
||||
<div className="space-y-6">
|
||||
{/* Média Card */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-lg">Média</CardTitle>
|
||||
<CardDescription className="text-xs">Sélectionnez ou uploadez</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
disabled={uploadMutation.isPending}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-[44px]"
|
||||
data-testid="button-camera-capture"
|
||||
>
|
||||
<Camera className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={open}
|
||||
disabled={uploadMutation.isPending}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-[44px]"
|
||||
data-testid="button-upload-new-media"
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
capture="environment"
|
||||
onChange={handleCameraCapture}
|
||||
className="hidden"
|
||||
/>
|
||||
<div {...getRootProps()} className={`${isDragActive ? 'bg-primary/5 border-primary' : ''}`}>
|
||||
<input {...getInputProps()} />
|
||||
{mediaList.length === 0 ? (
|
||||
<div className="text-center py-12 border-2 border-dashed rounded-lg">
|
||||
<ImageIcon className="w-12 h-12 mx-auto text-muted-foreground mb-3" />
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
{isDragActive ? "Déposez votre photo ici" : "Aucun média disponible"}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
disabled={uploadMutation.isPending}
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full min-h-[48px]"
|
||||
data-testid="button-camera-first"
|
||||
>
|
||||
<Camera className="w-5 h-5 mr-2" />
|
||||
Prendre une photo
|
||||
</Button>
|
||||
<Button
|
||||
onClick={open}
|
||||
disabled={uploadMutation.isPending}
|
||||
size="lg"
|
||||
className="w-full min-h-[48px]"
|
||||
data-testid="button-upload-first-media"
|
||||
>
|
||||
<Upload className="w-5 h-5 mr-2" />
|
||||
Uploader une photo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{selectedMedia.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-3">
|
||||
Photos sélectionnées ({selectedMedia.length}/10)
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedMedia}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3 p-4 bg-accent/20 rounded-lg border border-accent">
|
||||
{selectedMedia.map((mediaId, index) => {
|
||||
const media = mediaList.find(m => m.id === mediaId);
|
||||
if (!media) return null;
|
||||
|
||||
return (
|
||||
<SortableMediaItem
|
||||
key={media.id}
|
||||
media={media}
|
||||
index={index}
|
||||
isSelected={true}
|
||||
onToggle={() => {
|
||||
setSelectedMedia(prev => prev.filter(id => id !== media.id));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium text-muted-foreground mb-3">
|
||||
Toutes les photos ({mediaList.length})
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{mediaList.map((media) => {
|
||||
const isSelected = selectedMedia.includes(media.id);
|
||||
const isVideo = media.type === 'video';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={media.id}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setSelectedMedia(prev => prev.filter(id => id !== media.id));
|
||||
} else if (selectedMedia.length < 10) {
|
||||
setSelectedMedia(prev => [...prev, media.id]);
|
||||
} else {
|
||||
toast({
|
||||
title: "Limite atteinte",
|
||||
description: "Maximum 10 photos par publication",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all min-h-[44px] ${
|
||||
isSelected
|
||||
? 'border-primary ring-2 ring-primary opacity-50'
|
||||
: 'border-transparent hover:border-muted-foreground'
|
||||
}`}
|
||||
data-testid={`button-select-media-${media.id}`}
|
||||
>
|
||||
<MediaThumbnail
|
||||
src={media.facebookFeedUrl || media.originalUrl}
|
||||
alt={media.fileName}
|
||||
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||
type={isVideo ? 'video' : 'image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 bg-primary/10 flex items-center justify-center">
|
||||
<div className="text-xs font-semibold text-primary bg-white px-2 py-1 rounded">Sélectionnée</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contenu Card */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="text-lg">Contenu</CardTitle>
|
||||
<CardDescription className="text-xs">Informations du produit ou message</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="productInfo" className="text-sm">Informations du produit</Label>
|
||||
<Textarea
|
||||
id="productInfo"
|
||||
value={productInfo}
|
||||
onChange={(e) => setProductInfo(e.target.value)}
|
||||
placeholder="Décrivez votre produit : nom, caractéristiques, prix, etc."
|
||||
rows={5}
|
||||
className="mt-2"
|
||||
data-testid="input-product-info"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleGenerateText}
|
||||
disabled={generateTextMutation.isPending}
|
||||
className="w-full min-h-[48px]"
|
||||
size="lg"
|
||||
data-testid="button-generate-text"
|
||||
>
|
||||
<Sparkles className="w-5 h-5 mr-2" />
|
||||
{generateTextMutation.isPending ? 'Génération...' : 'Générer le texte IA'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Variations générées */}
|
||||
{generatedVariants.length > 0 && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="text-lg">Variations générées par l'IA</CardTitle>
|
||||
<CardDescription className="text-xs">Cliquez sur "Utiliser"</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{generatedVariants.map((variant, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 rounded-lg border bg-card"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-semibold text-muted-foreground">
|
||||
{variant.variant || `Version ${index + 1}`}
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{variant.text}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleUseVariant(variant.text)}
|
||||
className="w-full min-h-[44px]"
|
||||
data-testid={`button-use-variant-${index}`}
|
||||
>
|
||||
Utiliser
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Texte de la publication */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="text-lg">Texte de la publication</CardTitle>
|
||||
<CardDescription className="text-xs">Écrivez ou générez le texte</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={postText}
|
||||
onChange={(e) => setPostText(e.target.value)}
|
||||
placeholder="Écrivez votre texte ici ou générez-le avec l'IA..."
|
||||
rows={6}
|
||||
data-testid="textarea-post-text"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pages cibles */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="text-lg">Pages cibles</CardTitle>
|
||||
<CardDescription className="text-xs">Sélectionnez les pages</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pages.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground mb-3 text-sm">Aucune page connectée</p>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => navigate('/pages')}
|
||||
className="min-h-[44px]"
|
||||
data-testid="link-add-pages"
|
||||
>
|
||||
Ajouter des pages
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{pages.map((page) => (
|
||||
<div key={page.id} className="flex items-center space-x-3 p-3 rounded-lg border hover:bg-accent touch-manipulation">
|
||||
<Checkbox
|
||||
id={`page-${page.id}`}
|
||||
checked={selectedPages.includes(page.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedPages([...selectedPages, page.id]);
|
||||
} else {
|
||||
setSelectedPages(selectedPages.filter(id => id !== page.id));
|
||||
}
|
||||
}}
|
||||
className="w-5 h-5"
|
||||
data-testid={`checkbox-page-${page.id}`}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`page-${page.id}`}
|
||||
className="text-sm font-medium leading-relaxed flex-1 cursor-pointer"
|
||||
>
|
||||
{page.pageName} ({page.platform})
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Type de publication */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="text-lg">Type de publication</CardTitle>
|
||||
<CardDescription className="text-xs">Choisissez où publier</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Label htmlFor="postType">Format</Label>
|
||||
<Select value={postType} onValueChange={(value: 'feed' | 'story' | 'both') => setPostType(value)}>
|
||||
<SelectTrigger id="postType" className="min-h-[48px]" data-testid="select-post-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="feed" data-testid="option-feed">Feed uniquement</SelectItem>
|
||||
<SelectItem value="story" data-testid="option-story">Story uniquement</SelectItem>
|
||||
<SelectItem value="both" data-testid="option-both">Feed + Story</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{postType === 'feed' && 'Publication classique dans le fil d\'actualité'}
|
||||
{postType === 'story' && 'Story éphémère (24h) - Nécessite un média'}
|
||||
{postType === 'both' && 'Publie à la fois dans le feed et en story - Nécessite un média'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Planification */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="text-lg">Planification</CardTitle>
|
||||
<CardDescription className="text-xs">Programmez (optionnel)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Label>Date et heure</Label>
|
||||
<DateTimePicker
|
||||
value={scheduledDate}
|
||||
onChange={setScheduledDate}
|
||||
occupiedDates={scheduledPosts
|
||||
.filter(post => post.scheduledAt)
|
||||
.map(post => new Date(post.scheduledAt!))}
|
||||
placeholder="Publier immédiatement"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sélectionnez une date pour planifier, sinon publication immédiate
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bouton Publier - Fixed au bottom */}
|
||||
<div className="fixed bottom-0 left-0 right-0 p-4 bg-background border-t border-border shadow-lg">
|
||||
<Button
|
||||
onClick={() => setPreviewModalOpen(true)}
|
||||
disabled={createPostMutation.isPending}
|
||||
className="w-full bg-gradient-to-r from-primary to-secondary hover:opacity-90 shadow-lg min-h-[52px]"
|
||||
size="lg"
|
||||
data-testid="button-preview-post"
|
||||
>
|
||||
<Send className="w-5 h-5 mr-2" />
|
||||
Prévisualiser et publier
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Votre image est en cours de téléchargement et de traitement.
|
||||
<br />
|
||||
Veuillez patienter.
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<PreviewModal
|
||||
open={previewModalOpen}
|
||||
onOpenChange={setPreviewModalOpen}
|
||||
postText={postText}
|
||||
selectedMedia={selectedMedia}
|
||||
mediaList={mediaList}
|
||||
onPublish={handleCreatePost}
|
||||
isPublishing={createPostMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,657 +0,0 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useLocation } from "wouter";
|
||||
import {
|
||||
Send, Sparkles, Video, Music, Type, Calendar,
|
||||
Upload, Play, Pause, Volume2,
|
||||
ChevronRight, Loader2, Check, RefreshCw, ChevronLeft,
|
||||
Menu, Mic
|
||||
} from "lucide-react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { StoryToggle } from "@/components/reels/story-toggle";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
||||
import { ReelPreview } from "@/components/reels/reel-preview";
|
||||
import { SrtUpload, type SrtFile } from "@/components/reels/srt-upload";
|
||||
import { srtText } from "@shared/srt";
|
||||
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
||||
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
||||
import type { SocialPage, Media } from "@shared/schema";
|
||||
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface MusicTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
albumName: string;
|
||||
duration: number;
|
||||
previewUrl: string;
|
||||
downloadUrl: string;
|
||||
imageUrl: string;
|
||||
license: string;
|
||||
}
|
||||
|
||||
type Step = 'video' | 'music' | 'text' | 'publish';
|
||||
|
||||
export default function MobileNewReel() {
|
||||
const [, navigate] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
// État du workflow
|
||||
const [currentStep, setCurrentStep] = useState<Step>('video');
|
||||
|
||||
// État des données
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
|
||||
const [selectedVideo, setSelectedVideo] = useState<Media | null>(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState<MusicTrack | null>(null);
|
||||
const [overlayText, setOverlayText] = useState('');
|
||||
// Fichier SRT : remplace le texte libre, la voix suit son minutage
|
||||
const [srtFile, setSrtFile] = useState<SrtFile | null>(null);
|
||||
const [productInfo, setProductInfo] = useState('');
|
||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [selectedPages, setSelectedPages] = useState<string[]>([]);
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [musicVolume, setMusicVolume] = useState([25]);
|
||||
|
||||
// État TTS
|
||||
const [ttsEnabled, setTtsEnabled] = useState(false);
|
||||
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
||||
engine: 'gemini',
|
||||
voice: DEFAULT_VOICE.gemini,
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings;
|
||||
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
|
||||
// État audio preview
|
||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
|
||||
const [stabilize, setStabilize] = useState(false);
|
||||
const [alsoStory, setAlsoStory] = useState(false);
|
||||
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
|
||||
// TTS Sync state
|
||||
const [syncInfo, setSyncInfo] = useState<{
|
||||
wordDuration: number;
|
||||
audioDuration: number;
|
||||
wordCount: number;
|
||||
isHealthy: boolean;
|
||||
warnings: string[];
|
||||
} | null>(null);
|
||||
|
||||
// Auto-calculate TTS sync
|
||||
useEffect(() => {
|
||||
if (!ttsEnabled || !overlayText.trim() || srtFile) {
|
||||
setSyncInfo(null);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
apiRequest('POST', '/api/reels/sync-info', {
|
||||
text: overlayText,
|
||||
ttsEngine,
|
||||
ttsVoice,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => setSyncInfo(data))
|
||||
.catch(() => setSyncInfo(null));
|
||||
}, 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice, srtFile]);
|
||||
|
||||
|
||||
const { data: pages = [] } = useQuery<SocialPage[]>({
|
||||
queryKey: ['/api/pages'],
|
||||
});
|
||||
|
||||
// Destinations acceptant une vidéo verticale
|
||||
const facebookPages = pages.filter(p => p.platform === 'facebook');
|
||||
const tiktokAccounts = pages.filter(p => p.platform === 'tiktok');
|
||||
|
||||
const renderTargetCheckbox = (page: SocialPage) => (
|
||||
<div key={page.id} className="flex items-center space-x-3 bg-secondary/20 p-3 rounded-lg">
|
||||
<Checkbox
|
||||
id={page.id}
|
||||
checked={selectedPages.includes(page.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) setSelectedPages([...selectedPages, page.id]);
|
||||
else setSelectedPages(selectedPages.filter(id => id !== page.id));
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={page.id} className="font-medium flex-1">{page.pageName}</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
const { data: allMedia = [] } = useQuery<Media[]>({
|
||||
queryKey: ['/api/media'],
|
||||
});
|
||||
|
||||
const videoList = allMedia.filter(m => m.type === 'video').slice(0, 12);
|
||||
|
||||
// --- Added for Internal Audio Tracks ---
|
||||
const { data: internalTracksResponse = [], isLoading: internalTracksLoading } = useQuery<any[]>({
|
||||
queryKey: ['/api/audio-tracks'],
|
||||
});
|
||||
|
||||
const internalTracks: MusicTrack[] = (internalTracksResponse || []).map(t => ({
|
||||
id: `internal_${t.id}`,
|
||||
title: t.title,
|
||||
artist: t.fileName || t.title,
|
||||
albumName: "Bibliothèque Interne",
|
||||
duration: t.duration || 0,
|
||||
previewUrl: t.url,
|
||||
downloadUrl: t.url,
|
||||
imageUrl: "",
|
||||
license: "Internal"
|
||||
}));
|
||||
// --- End Internal Tracks ---
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await fetch("/api/media/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) handleUnauthorized("/api/media/upload");
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Upload failed: ${response.status} ${response.statusText} - ${errorText.substring(0, 100)}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
|
||||
setSelectedVideoId(data.id);
|
||||
setSelectedVideo(data);
|
||||
toast({ title: "Succès", description: "Vidéo téléchargée" });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Upload error details:", error);
|
||||
toast({
|
||||
title: "Erreur Upload",
|
||||
description: error.message || "Échec inattendu",
|
||||
variant: "destructive",
|
||||
duration: 5000
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const generateTextMutation = useMutation({
|
||||
mutationFn: async (productInfoText: string) => {
|
||||
const response = await apiRequest('POST', '/api/reels/generate-text', {
|
||||
productInfo: productInfoText,
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
setGeneratedVariants(data.variants || []);
|
||||
toast({ title: "Généré", description: "Variations créées" });
|
||||
},
|
||||
});
|
||||
|
||||
const createReelMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
const response = await apiRequest('POST', '/api/reels', data);
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
||||
toast({
|
||||
title: "Création du Reel lancée",
|
||||
description: data.queued
|
||||
? "Un autre Reel est en cours : le vôtre démarrera juste après."
|
||||
: "Suivez l'avancement sur l'accueil.",
|
||||
});
|
||||
navigate('/');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({ title: "Erreur", description: error.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0 && acceptedFiles[0].type.startsWith('video/')) {
|
||||
uploadMutation.mutate(acceptedFiles[0]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
||||
onDrop,
|
||||
accept: { "video/*": [".mp4", ".mov", ".webm"] },
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
});
|
||||
|
||||
|
||||
|
||||
const togglePlayPreview = async (track: MusicTrack) => {
|
||||
if (isPlaying === track.id) {
|
||||
audioRef.current?.pause();
|
||||
setIsPlaying(null);
|
||||
} else {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.src = track.previewUrl;
|
||||
try {
|
||||
await audioRef.current.play();
|
||||
setIsPlaying(track.id);
|
||||
} catch {
|
||||
setIsPlaying(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateReel = () => {
|
||||
if (!selectedVideoId || selectedPages.length === 0) {
|
||||
toast({ title: "Erreur", description: "Vidéo et page requises", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
createReelMutation.mutate({
|
||||
videoMediaId: selectedVideoId,
|
||||
musicTrackId: selectedTrack?.id,
|
||||
overlayText: srtFile ? undefined : overlayText,
|
||||
srtCues: srtFile?.cues,
|
||||
description: srtFile ? srtText(srtFile.cues) : overlayText,
|
||||
pageIds: selectedPages,
|
||||
scheduledFor: scheduledDate?.toISOString(),
|
||||
alsoStory,
|
||||
musicVolume: musicVolume[0] / 100,
|
||||
stabilize: stabilize,
|
||||
drawText: true,
|
||||
ttsEnabled,
|
||||
ttsEngine,
|
||||
ttsVoice,
|
||||
ttsStyle,
|
||||
captionStyle,
|
||||
enableEndingEffect,
|
||||
showLogo,
|
||||
});
|
||||
};
|
||||
|
||||
const steps: Step[] = ['video', 'music', 'text', 'publish'];
|
||||
const currentStepIndex = steps.indexOf(currentStep);
|
||||
|
||||
const goNext = () => {
|
||||
if (currentStepIndex < steps.length - 1) setCurrentStep(steps[currentStepIndex + 1]);
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (currentStepIndex > 0) setCurrentStep(steps[currentStepIndex - 1]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background pb-20">
|
||||
{/* Header Mobile */}
|
||||
<div className="sticky top-0 z-50 bg-background/95 backdrop-blur border-b p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="-ml-2">
|
||||
<Menu className="h-6 w-6" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="p-0 w-[80%]">
|
||||
<Sidebar />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<h1 className="font-semibold text-lg">Nouveau Reel</h1>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground font-medium">
|
||||
{currentStepIndex + 1}/{steps.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="p-4 space-y-6">
|
||||
{/* VIDEO STEP */}
|
||||
{currentStep === 'video' && (
|
||||
<div className="space-y-4">
|
||||
|
||||
<div {...getRootProps()} className={`border-2 border-dashed rounded-xl p-6 text-center ${isDragActive ? 'border-primary bg-primary/5' : 'border-muted'}`}>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
<div className="flex justify-center mb-6">
|
||||
<Button onClick={open} variant="outline" size="lg" className="h-12">
|
||||
<Upload className="mr-2 h-5 w-5" /> Galerie
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
{selectedVideo ? (
|
||||
<div className="relative rounded-lg overflow-hidden aspect-[9/16] bg-black max-h-[50vh] mx-auto shadow-lg">
|
||||
<video src={selectedVideo.originalUrl} className="w-full h-full object-contain" controls />
|
||||
<div className="absolute top-2 right-2 bg-primary text-white text-xs px-2 py-1 rounded-full flex items-center shadow-sm">
|
||||
<Check className="w-3 h-3 mr-1" /> Sélectionné
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||
{videoList.map((media) => (
|
||||
<div key={media.id} onClick={() => { setSelectedVideoId(media.id); setSelectedVideo(media); }} className={`relative aspect-[9/16] bg-black rounded-lg overflow-hidden border-2 transition-all ${selectedVideoId === media.id ? 'border-primary' : 'border-transparent'}`}>
|
||||
<video src={media.originalUrl} className="w-full h-full object-cover" muted />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary" /> Stabilisation Vidéo (Anti-tremblements)
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Corrige les secousses (Recommandé main levée). Désactiver sur trépied.</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={stabilize}
|
||||
onCheckedChange={(checked) => setStabilize(!!checked)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedVideo && (
|
||||
<Button className="w-full h-12 text-lg sticky bottom-4 shadow-xl" onClick={goNext}>
|
||||
Suivant <ChevronRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MUSIC STEP */}
|
||||
{currentStep === 'music' && (
|
||||
<div className="space-y-4">
|
||||
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
|
||||
|
||||
{selectedVideo && (
|
||||
<div className="aspect-video bg-black rounded-lg overflow-hidden h-32 mx-auto">
|
||||
<video src={selectedVideo.originalUrl} className="w-full h-full object-cover opacity-50" muted />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{internalTracksLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : internalTracks.length === 0 ? (
|
||||
<div className="text-center py-10 border-2 border-dashed border-border rounded-xl">
|
||||
<Music className="w-12 h-12 mx-auto text-muted-foreground mb-4 opacity-40" />
|
||||
<p className="text-muted-foreground font-medium">Aucune musique disponible</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Un administrateur peut ajouter des MP3 via la Bibliothèque Audio.</p>
|
||||
</div>
|
||||
) : (
|
||||
internalTracks.map((track) => (
|
||||
<div key={track.id} onClick={() => setSelectedTrack(track)} className={`flex items-center gap-3 p-3 rounded-xl border ${selectedTrack?.id === track.id ? 'border-primary bg-primary/5' : 'border-border bg-card'}`}>
|
||||
<Button size="icon" variant="ghost" className="h-10 w-10 rounded-full bg-primary/10" onClick={(e) => { e.stopPropagation(); togglePlayPreview(track); }}>
|
||||
{isPlaying === track.id ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />}
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{track.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
{selectedTrack?.id === track.id && <Check className="h-5 w-5 text-primary" />}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedTrack && (
|
||||
<div className="bg-card p-4 rounded-xl border mt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Volume2 className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-medium">Volume: {musicVolume[0]}%</span>
|
||||
</div>
|
||||
<Slider value={musicVolume} onValueChange={setMusicVolume} max={100} step={5} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 sticky bottom-4">
|
||||
<Button variant="outline" onClick={goBack} className="flex-1 h-12">Retour</Button>
|
||||
<Button onClick={goNext} className="flex-1 h-12">Suivant</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TEXT STEP */}
|
||||
{currentStep === 'text' && (
|
||||
<div className="space-y-6">
|
||||
{!srtFile && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center"><Sparkles className="w-4 h-4 mr-2 text-primary" /> Assistant IA</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
placeholder="Décrivez le produit..."
|
||||
value={productInfo}
|
||||
onChange={(e) => setProductInfo(e.target.value)}
|
||||
className="mb-3"
|
||||
/>
|
||||
<Button onClick={() => generateTextMutation.mutate(productInfo)} disabled={generateTextMutation.isPending} className="w-full" variant="secondary">
|
||||
{generateTextMutation.isPending ? <Loader2 className="animate-spin mr-2" /> : <Sparkles className="mr-2 w-4 h-4" />}
|
||||
Générer
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!srtFile && generatedVariants.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{generatedVariants.map((v, i) => (
|
||||
<div key={i} className="bg-card p-3 rounded-lg border text-sm" onClick={() => { setOverlayText(v.text); toast({ title: "Texte appliqué" }); }}>
|
||||
{v.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Texte Overlay</label>
|
||||
<SrtUpload value={srtFile} onChange={setSrtFile} />
|
||||
{srtFile ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Texte libre désactivé : le fichier SRT est utilisé. Retirez-le pour écrire un texte.
|
||||
</p>
|
||||
) : (
|
||||
<Textarea
|
||||
value={overlayText}
|
||||
onChange={(e) => setOverlayText(e.target.value)}
|
||||
placeholder="Texte sur la vidéo..."
|
||||
className="text-lg"
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1.5 mt-3">
|
||||
<Label className="text-xs font-medium">Style des sous-titres</Label>
|
||||
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<Switch
|
||||
id="show-logo"
|
||||
checked={showLogo}
|
||||
onCheckedChange={setShowLogo}
|
||||
/>
|
||||
<Label htmlFor="show-logo" className="font-medium cursor-pointer">
|
||||
Afficher le logo sur la vidéo
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<Switch
|
||||
id="enable-ending-effect"
|
||||
checked={enableEndingEffect}
|
||||
onCheckedChange={setEnableEndingEffect}
|
||||
/>
|
||||
<Label htmlFor="enable-ending-effect" className="font-medium cursor-pointer">
|
||||
Activer l'effet de fin (logo + fondu)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<Switch
|
||||
id="tts-mode"
|
||||
checked={ttsEnabled}
|
||||
onCheckedChange={setTtsEnabled}
|
||||
/>
|
||||
<Label htmlFor="tts-mode" className="font-medium cursor-pointer">
|
||||
Activer la lecture voix (TTS)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{ttsEnabled && (
|
||||
<div className="mt-4 space-y-2 ml-1 p-3 bg-muted/30 rounded-lg border border-border/50">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Mic className="w-4 h-4" />
|
||||
<span>TTS — voix activée</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<VoicePicker
|
||||
value={voiceSettings}
|
||||
onChange={setVoiceSettings}
|
||||
sampleText={srtFile ? srtFile.cues[0]?.text : overlayText}
|
||||
onPreview={setVoicePreview}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{syncInfo && !srtFile && (
|
||||
<div className={`p-2 rounded-lg border mt-2 ${syncInfo.isHealthy ? 'bg-green-500/10 border-green-500/30' : 'bg-yellow-500/10 border-yellow-500/30'}`}>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">Sync</span>
|
||||
<span className={syncInfo.isHealthy ? 'text-green-600' : 'text-yellow-600'}>
|
||||
{syncInfo.wordCount} mots · {syncInfo.audioDuration.toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
{syncInfo.warnings.map((w, i) => (
|
||||
<p key={i} className="text-[10px] text-yellow-600 mt-0.5">⚠️ {w}</p>
|
||||
))}
|
||||
{syncInfo.isHealthy && syncInfo.warnings.length === 0 && (
|
||||
<p className="text-[10px] text-green-600 mt-0.5">✅ Timing OK</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 sticky bottom-4">
|
||||
<Button variant="outline" onClick={goBack} className="flex-1 h-12">Retour</Button>
|
||||
<Button onClick={goNext} className="flex-1 h-12">Suivant</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PUBLISH STEP */}
|
||||
{currentStep === 'publish' && (
|
||||
<div className="space-y-6">
|
||||
{selectedVideo && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-[260px] mx-auto">
|
||||
<ReelPreview
|
||||
kind="video"
|
||||
videoUrl={selectedVideo.originalUrl}
|
||||
text={srtFile ? srtText(srtFile.cues) : overlayText}
|
||||
srtCues={srtFile?.cues}
|
||||
showCaptions
|
||||
captionStyle={captionStyle}
|
||||
ttsEnabled={ttsEnabled}
|
||||
voice={currentVoice}
|
||||
musicUrl={selectedTrack?.previewUrl}
|
||||
musicVolume={musicVolume[0] / 100}
|
||||
logoUrl={reelConfig?.logoUrl}
|
||||
showWatermark={showLogo}
|
||||
storeName={pages.find((p) => p.id === selectedPages[0])?.pageName}
|
||||
endingEffect={enableEndingEffect}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Diffusion</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{facebookPages.length === 0 && tiktokAccounts.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aucune page Facebook ni compte TikTok connecté.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{facebookPages.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
||||
Pages Facebook
|
||||
</div>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tiktokAccounts.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiTiktok className="w-4 h-4" />
|
||||
Comptes TikTok
|
||||
</div>
|
||||
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center"><Calendar className="mr-2 w-4 h-4" /> Planification</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DateTimePicker value={scheduledDate} onChange={setScheduledDate} placeholder="Maintenant" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-3 sticky bottom-4 pb-4 bg-background">
|
||||
<Button variant="outline" onClick={goBack} className="flex-1 h-12">Retour</Button>
|
||||
<Button
|
||||
onClick={handleCreateReel}
|
||||
disabled={createReelMutation.isPending}
|
||||
className="flex-[2] h-12 bg-primary text-primary-foreground font-bold shadow-lg"
|
||||
>
|
||||
{createReelMutation.isPending ? <Loader2 className="animate-spin mr-2" /> : <Send className="mr-2 w-5 h-5" />}
|
||||
{scheduledDate ? 'Programmer' : 'Publier'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,533 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code } from "lucide-react";
|
||||
import { SiTiktok } from "react-icons/si";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import type { ClientSocialPage } from "@shared/schema";
|
||||
import {
|
||||
ConnectFacebookButton,
|
||||
RefreshTokenButton,
|
||||
TokenAlertBanner,
|
||||
TokenHealthPanel,
|
||||
} from "@/components/facebook-token-status";
|
||||
|
||||
/**
|
||||
* Lance l'autorisation TikTok. La connexion se fait par navigation complète du
|
||||
* navigateur (et non en fetch) puisqu'elle passe par le site de TikTok.
|
||||
*/
|
||||
function ConnectTiktokButton() {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: config } = useQuery<{ configured: boolean }>({
|
||||
queryKey: ['/api/tiktok/config'],
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
if (config && !config.configured) {
|
||||
toast({
|
||||
title: "TikTok n'est pas configuré",
|
||||
description: "Renseignez le client key et le client secret de votre application TikTok dans les paramètres.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.location.href = '/api/tiktok/connect';
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full min-h-[48px]"
|
||||
onClick={handleClick}
|
||||
data-testid="button-connect-tiktok"
|
||||
>
|
||||
<SiTiktok className="w-5 h-5 mr-2" />
|
||||
Connecter un compte TikTok
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PagesManagementMobile() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback
|
||||
// redirigent ici avec le résultat en paramètre)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null;
|
||||
if (!network) return;
|
||||
|
||||
const result = params.get(network);
|
||||
const label = network === 'tiktok' ? 'TikTok' : 'Facebook';
|
||||
|
||||
toast({
|
||||
title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`,
|
||||
description: params.get('message') || undefined,
|
||||
variant: result === 'success' ? undefined : 'destructive',
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}, [toast]);
|
||||
|
||||
const { data: pages = [], isLoading } = useQuery<ClientSocialPage[]>({
|
||||
queryKey: ['/api/pages'],
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (pageId: string) =>
|
||||
apiRequest('DELETE', `/api/pages/${pageId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
|
||||
toast({
|
||||
title: "Page supprimée",
|
||||
description: "La page a été retirée avec succès",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la page",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
{/* MOBILE: Compact layout with single column */}
|
||||
<div className="p-4 space-y-6 pb-24">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-foreground">Pages gérées</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connectez et gérez vos pages Facebook, Instagram et vos comptes TikTok
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TokenAlertBanner pages={pages} />
|
||||
|
||||
<ConnectFacebookButton className="w-full min-h-[48px]" />
|
||||
<ConnectTiktokButton />
|
||||
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
|
||||
|
||||
{/* MOBILE: Stack buttons vertically */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open('https://developers.facebook.com/tools/debug/accesstoken/', '_blank')}
|
||||
className="w-full min-h-[48px]"
|
||||
data-testid="button-debug-token"
|
||||
>
|
||||
<Bug className="w-5 h-5 mr-2" />
|
||||
Débogueur de jeton
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open('https://developers.facebook.com/tools/explorer', '_blank')}
|
||||
className="w-full min-h-[48px]"
|
||||
data-testid="button-graph-explorer"
|
||||
>
|
||||
<Code className="w-5 h-5 mr-2" />
|
||||
Graph Explorer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-48 bg-card rounded-2xl border border-border/50 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : pages.length === 0 ? (
|
||||
<Card className="border-dashed rounded-2xl border-border/50 shadow-lg">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<Plus className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2 text-center">Aucune page connectée</h3>
|
||||
<p className="text-muted-foreground text-center mb-4 text-sm px-4">
|
||||
Commencez par connecter vos pages Facebook et Instagram
|
||||
</p>
|
||||
<Button onClick={() => setDialogOpen(true)} className="min-h-[48px]" data-testid="button-add-first-page">
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Ajouter une page
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{pages.map((page) => (
|
||||
<Card key={page.id} className="rounded-2xl border-border/50 shadow-lg" data-testid={`card-page-${page.id}`}>
|
||||
<CardHeader className="p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
{page.platform === 'facebook' ? (
|
||||
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Facebook className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
) : page.platform === 'tiktok' ? (
|
||||
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<SiTiktok className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Instagram className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base truncate">{page.pageName}</CardTitle>
|
||||
<CardDescription className="capitalize text-xs">
|
||||
{page.platform}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-5 pt-0">
|
||||
<div className="text-xs text-muted-foreground break-all">
|
||||
ID: {page.pageId}
|
||||
</div>
|
||||
|
||||
{/* État du jeton, tel que constaté par le dernier contrôle serveur */}
|
||||
<TokenHealthPanel page={page} />
|
||||
|
||||
{page.platform !== 'tiktok' && (
|
||||
<div className="flex">
|
||||
<RefreshTokenButton page={page} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{page.platform === 'tiktok' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 min-h-[44px]"
|
||||
onClick={() => { window.location.href = '/api/tiktok/connect'; }}
|
||||
data-testid={`button-reconnect-page-${page.id}`}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reconnecter
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 min-h-[44px]"
|
||||
onClick={() => setEditingPage(page)}
|
||||
data-testid={`button-edit-page-${page.id}`}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1 min-h-[44px]"
|
||||
onClick={() => deleteMutation.mutate(page.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
data-testid={`button-delete-page-${page.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fixed Add Button */}
|
||||
<div className="fixed bottom-0 left-0 right-0 p-4 bg-background border-t border-border shadow-lg">
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="w-full min-h-[52px]"
|
||||
data-testid="button-add-page"
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Ajouter une page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const [platform, setPlatform] = useState<'facebook' | 'instagram'>('facebook');
|
||||
const [pageName, setPageName] = useState('');
|
||||
const [pageId, setPageId] = useState('');
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
const { toast } = useToast();
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
apiRequest('POST', '/api/pages', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
|
||||
toast({
|
||||
title: "Page ajoutée",
|
||||
description: "La page a été connectée avec succès",
|
||||
});
|
||||
onOpenChange(false);
|
||||
setPlatform('facebook');
|
||||
setPageName('');
|
||||
setPageId('');
|
||||
setAccessToken('');
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible d'ajouter la page",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
addMutation.mutate({
|
||||
platform,
|
||||
pageName,
|
||||
pageId,
|
||||
accessToken,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] max-w-md mx-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ajouter une page</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Connectez une page Facebook ou Instagram pour commencer à publier
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="platform" className="text-sm">Plateforme</Label>
|
||||
<Select value={platform} onValueChange={(v) => setPlatform(v as any)}>
|
||||
<SelectTrigger className="min-h-[48px]" data-testid="select-platform">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="facebook">Facebook</SelectItem>
|
||||
<SelectItem value="instagram">Instagram</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pageName" className="text-sm">Nom de la page</Label>
|
||||
<Input
|
||||
id="pageName"
|
||||
value={pageName}
|
||||
onChange={(e) => setPageName(e.target.value)}
|
||||
placeholder="Mon entreprise"
|
||||
required
|
||||
className="min-h-[48px]"
|
||||
data-testid="input-page-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pageId" className="text-sm">ID de la page</Label>
|
||||
<Input
|
||||
id="pageId"
|
||||
value={pageId}
|
||||
onChange={(e) => setPageId(e.target.value)}
|
||||
placeholder="123456789"
|
||||
required
|
||||
className="min-h-[48px]"
|
||||
data-testid="input-page-id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accessToken" className="text-sm">Jeton d'accès</Label>
|
||||
<Input
|
||||
id="accessToken"
|
||||
type="password"
|
||||
value={accessToken}
|
||||
onChange={(e) => setAccessToken(e.target.value)}
|
||||
placeholder="EAAxxxxxxxxxxxxx"
|
||||
required
|
||||
className="min-h-[48px]"
|
||||
data-testid="input-access-token"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Obtenez votre jeton depuis{' '}
|
||||
<a
|
||||
href="https://developers.facebook.com/tools/explorer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Graph API Explorer
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={addMutation.isPending}
|
||||
className="w-full min-h-[48px]"
|
||||
data-testid="button-submit-page"
|
||||
>
|
||||
{addMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Ajout...
|
||||
</>
|
||||
) : (
|
||||
'Ajouter'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="w-full min-h-[48px]"
|
||||
data-testid="button-cancel"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditPageDialog({ page, onOpenChange }: { page: ClientSocialPage | null; onOpenChange: (open: boolean) => void }) {
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
const { toast } = useToast();
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
apiRequest('PUT', `/api/pages/${page?.id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
|
||||
toast({
|
||||
title: "Page modifiée",
|
||||
description: "Le jeton d'accès a été mis à jour avec succès",
|
||||
});
|
||||
onOpenChange(false);
|
||||
setAccessToken('');
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de modifier la page",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!page) return;
|
||||
editMutation.mutate({
|
||||
accessToken,
|
||||
});
|
||||
};
|
||||
|
||||
if (!page) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={!!page} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] max-w-md mx-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier le jeton d'accès</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Mettez à jour le jeton d'accès pour {page.pageName}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="editAccessToken" className="text-sm">Nouveau jeton d'accès</Label>
|
||||
<Input
|
||||
id="editAccessToken"
|
||||
type="password"
|
||||
value={accessToken}
|
||||
onChange={(e) => setAccessToken(e.target.value)}
|
||||
placeholder="EAAxxxxxxxxxxxxx"
|
||||
required
|
||||
className="min-h-[48px]"
|
||||
data-testid="input-edit-access-token"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Obtenez un nouveau jeton depuis{' '}
|
||||
<a
|
||||
href="https://developers.facebook.com/tools/explorer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Graph API Explorer
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={editMutation.isPending}
|
||||
className="w-full min-h-[48px]"
|
||||
data-testid="button-submit-edit"
|
||||
>
|
||||
{editMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Modification...
|
||||
</>
|
||||
) : (
|
||||
'Modifier'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="w-full min-h-[48px]"
|
||||
data-testid="button-cancel-edit"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,585 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Settings as SettingsIcon, Bell, Brain, Cloud, Image, Upload, X, Plug, Key } from "lucide-react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { ModelCombobox } from "@/components/model-combobox";
|
||||
|
||||
export default function SettingsMobile() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [notifications, setNotifications] = useState(true);
|
||||
const [autoPublish, setAutoPublish] = useState(true);
|
||||
const [cloudName, setCloudName] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [apiSecret, setApiSecret] = useState("");
|
||||
const [endpointUrl, setEndpointUrl] = useState("");
|
||||
const [publicUrl, setPublicUrl] = useState("");
|
||||
const [openrouterApiKey, setOpenrouterApiKey] = useState("");
|
||||
const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-sonnet-4.5");
|
||||
const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [externalApiKey, setExternalApiKey] = useState("");
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
|
||||
queryKey: ["/api/auth/session"],
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const isAdmin = session?.role === "admin";
|
||||
|
||||
const { data: cloudinaryConfig } = useQuery({
|
||||
queryKey: ['/api/cloudinary/config'],
|
||||
});
|
||||
|
||||
const { data: openrouterConfig } = useQuery({
|
||||
queryKey: ['/api/openrouter/config'],
|
||||
});
|
||||
|
||||
const { data: openrouterModels, isLoading: modelsLoading } = useQuery({
|
||||
queryKey: ['/api/openrouter/models'],
|
||||
// Liste servie par un appel sortant vers OpenRouter : elle ne bouge pas d'une
|
||||
// heure à l'autre, inutile de la redemander à chaque retour sur l'onglet.
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (cloudinaryConfig) {
|
||||
setCloudName((cloudinaryConfig as any).cloudName || "");
|
||||
setApiKey((cloudinaryConfig as any).apiKey || "");
|
||||
setEndpointUrl((cloudinaryConfig as any).endpointUrl || "");
|
||||
setPublicUrl((cloudinaryConfig as any).publicUrl || "");
|
||||
|
||||
if ((cloudinaryConfig as any).logoUrl) {
|
||||
setLogoPreview((cloudinaryConfig as any).logoUrl);
|
||||
}
|
||||
}
|
||||
}, [cloudinaryConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (openrouterConfig) {
|
||||
setOpenrouterModel((openrouterConfig as any).model || "anthropic/claude-sonnet-4.5");
|
||||
setOpenrouterSystemPrompt((openrouterConfig as any).systemPrompt || "Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
|
||||
}
|
||||
}, [openrouterConfig]);
|
||||
|
||||
const hasExistingConfig = !!openrouterConfig;
|
||||
|
||||
const { data: externalApiConfig } = useQuery({
|
||||
queryKey: ['/api/settings/external-api'],
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const hasExistingExternalApiConfig = !!(externalApiConfig as any)?.configured;
|
||||
|
||||
const saveExternalApiMutation = useMutation({
|
||||
mutationFn: () => apiRequest('POST', '/api/settings/external-api', { apiKey: externalApiKey }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/settings/external-api'] });
|
||||
setExternalApiKey("");
|
||||
toast({ title: "Clé API sauvegardée", description: "La clé API externe a été enregistrée" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Erreur", description: "Impossible de sauvegarder la clé API", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteExternalApiMutation = useMutation({
|
||||
mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/settings/external-api'] });
|
||||
toast({ title: "Clé API supprimée", description: "L'accès à l'API externe a été révoqué" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Erreur", description: "Impossible de supprimer la clé API", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const saveCloudinaryMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload: any = {
|
||||
cloudName,
|
||||
endpointUrl: endpointUrl || undefined,
|
||||
publicUrl: publicUrl || undefined,
|
||||
};
|
||||
|
||||
if (apiKey && apiKey.trim() !== "") {
|
||||
payload.apiKey = apiKey;
|
||||
}
|
||||
if (apiSecret && apiSecret.trim() !== "") {
|
||||
payload.apiSecret = apiSecret;
|
||||
}
|
||||
|
||||
return apiRequest('POST', '/api/cloudinary/config', payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
|
||||
toast({
|
||||
title: "Configuration sauvegardée",
|
||||
description: "Vos paramètres Cloudinary ont été enregistrés",
|
||||
});
|
||||
setApiKey("");
|
||||
setApiSecret("");
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de sauvegarder la configuration",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const saveOpenrouterMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload: any = {
|
||||
model: openrouterModel,
|
||||
systemPrompt: openrouterSystemPrompt,
|
||||
};
|
||||
|
||||
if (openrouterApiKey && openrouterApiKey.trim() !== "") {
|
||||
payload.apiKey = openrouterApiKey;
|
||||
}
|
||||
|
||||
return apiRequest('POST', '/api/openrouter/config', payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/openrouter/config'] });
|
||||
toast({
|
||||
title: "Configuration sauvegardée",
|
||||
description: "Vos paramètres OpenRouter ont été enregistrés",
|
||||
});
|
||||
setOpenrouterApiKey("");
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de sauvegarder la configuration OpenRouter",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const uploadLogoMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
|
||||
const response = await fetch('/api/cloudinary/logo', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to upload logo');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
|
||||
toast({
|
||||
title: "Logo uploadé",
|
||||
description: "Votre logo a été enregistré avec succès",
|
||||
});
|
||||
setLogoFile(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Impossible d'uploader le logo",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteLogoMutation = useMutation({
|
||||
mutationFn: () => apiRequest('DELETE', '/api/cloudinary/logo', {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
|
||||
setLogoPreview(null);
|
||||
setLogoFile(null);
|
||||
toast({
|
||||
title: "Logo supprimé",
|
||||
description: "Votre logo a été supprimé avec succès",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer le logo",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Veuillez sélectionner une image",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLogoFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setLogoPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
{/* MOBILE: Single column layout */}
|
||||
<div className="p-4 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-foreground">Paramètres</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configurez votre application
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bell className="w-5 h-5" />
|
||||
Notifications
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Gérez vos préférences de notification
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-5 pt-0">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg border">
|
||||
<div className="space-y-0.5 flex-1 pr-3">
|
||||
<Label htmlFor="notifications" className="text-sm">Notifications activées</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recevez des notifications pour les publications
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifications"
|
||||
checked={notifications}
|
||||
onCheckedChange={setNotifications}
|
||||
data-testid="switch-notifications"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg border">
|
||||
<div className="space-y-0.5 flex-1 pr-3">
|
||||
<Label htmlFor="autoPublish" className="text-sm">Publication automatique</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Publier automatiquement à l'heure programmée
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="autoPublish"
|
||||
checked={autoPublish}
|
||||
onCheckedChange={setAutoPublish}
|
||||
data-testid="switch-auto-publish"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* OpenRouter */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Brain className="w-5 h-5" />
|
||||
Configuration OpenRouter (IA)
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Configurez OpenRouter pour la génération de texte avec l'IA
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-5 pt-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openrouterApiKey" className="text-sm">Clé API OpenRouter</Label>
|
||||
<Input
|
||||
id="openrouterApiKey"
|
||||
type="password"
|
||||
value={openrouterApiKey}
|
||||
onChange={(e) => setOpenrouterApiKey(e.target.value)}
|
||||
placeholder={hasExistingConfig ? "••••••••••••••••" : "sk-or-v1-..."}
|
||||
className="min-h-[48px]"
|
||||
data-testid="input-openrouter-api-key"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{hasExistingConfig ? (
|
||||
<>
|
||||
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée</span> - Laissez vide pour garder la clé actuelle
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Obtenez votre clé API sur{" "}
|
||||
<a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer" className="underline">
|
||||
openrouter.ai
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openrouterModel" className="text-sm">Modèle d'IA</Label>
|
||||
<ModelCombobox
|
||||
models={(openrouterModels as any)?.data || []}
|
||||
value={openrouterModel}
|
||||
onValueChange={setOpenrouterModel}
|
||||
placeholder="Sélectionner un modèle"
|
||||
isLoading={modelsLoading}
|
||||
className="w-full"
|
||||
testId="select-openrouter-model"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choisissez le modèle d'IA pour générer vos publications
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openrouterSystemPrompt" className="text-sm">Prompt système</Label>
|
||||
<Textarea
|
||||
id="openrouterSystemPrompt"
|
||||
value={openrouterSystemPrompt}
|
||||
onChange={(e) => setOpenrouterSystemPrompt(e.target.value)}
|
||||
placeholder="Instructions pour l'IA..."
|
||||
rows={5}
|
||||
data-testid="textarea-openrouter-system-prompt"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Définissez les instructions que l'IA doit suivre pour générer vos textes
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => saveOpenrouterMutation.mutate()}
|
||||
disabled={
|
||||
saveOpenrouterMutation.isPending ||
|
||||
!openrouterModel ||
|
||||
!openrouterSystemPrompt ||
|
||||
(!hasExistingConfig && !openrouterApiKey)
|
||||
}
|
||||
data-testid="button-save-openrouter"
|
||||
className="w-full min-h-[48px]"
|
||||
>
|
||||
{saveOpenrouterMutation.isPending ? "Enregistrement..." : "Enregistrer OpenRouter"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stockage local */}
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Cloud className="w-5 h-5" />
|
||||
Stockage des médias
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Les médias sont stockés localement sur le serveur
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5 pt-0">
|
||||
<div className="rounded-lg bg-muted p-4 space-y-1 text-sm text-muted-foreground">
|
||||
<p>✓ Images et vidéos stockées sur le disque du serveur</p>
|
||||
<p>✓ Vidéos supprimées automatiquement après publication</p>
|
||||
<p>✓ Images supprimées automatiquement après 30 jours</p>
|
||||
<p>✓ Vidéos non publiées supprimées après 7 jours</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Logo */}
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Image className="w-5 h-5" />
|
||||
Logo de l'entreprise
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Uploadez votre logo pour l'ajouter aux images éditées
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-5 pt-0">
|
||||
{logoPreview ? (
|
||||
<div className="space-y-4">
|
||||
<div className="relative w-full max-w-xs mx-auto">
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Logo"
|
||||
className="w-full h-auto max-h-48 object-contain rounded-lg border-2 border-border"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
className="absolute top-2 right-2 h-10 w-10"
|
||||
onClick={() => deleteLogoMutation.mutate()}
|
||||
disabled={deleteLogoMutation.isPending}
|
||||
data-testid="button-delete-logo"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="logo-upload-replace" className="cursor-pointer">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full min-h-[48px]"
|
||||
disabled={uploadLogoMutation.isPending}
|
||||
data-testid="button-replace-logo"
|
||||
onClick={() => document.getElementById('logo-upload-replace')?.click()}
|
||||
>
|
||||
<Upload className="w-5 h-5 mr-2" />
|
||||
{uploadLogoMutation.isPending ? "Upload en cours..." : "Remplacer le logo"}
|
||||
</Button>
|
||||
</Label>
|
||||
<input
|
||||
id="logo-upload-replace"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleLogoChange(e);
|
||||
if (e.target.files?.[0]) {
|
||||
uploadLogoMutation.mutate(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center">
|
||||
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Aucun logo uploadé. Choisissez une image PNG ou JPG.
|
||||
</p>
|
||||
<Label htmlFor="logo-upload" className="cursor-pointer">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={uploadLogoMutation.isPending || !cloudinaryConfig}
|
||||
className="min-h-[48px]"
|
||||
data-testid="button-upload-logo"
|
||||
onClick={() => document.getElementById('logo-upload')?.click()}
|
||||
>
|
||||
<Upload className="w-5 h-5 mr-2" />
|
||||
{uploadLogoMutation.isPending ? "Upload en cours..." : "Uploader un logo"}
|
||||
</Button>
|
||||
</Label>
|
||||
<input
|
||||
id="logo-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleLogoChange(e);
|
||||
if (e.target.files?.[0]) {
|
||||
uploadLogoMutation.mutate(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!cloudinaryConfig && (
|
||||
<p className="text-xs text-orange-600 dark:text-orange-400 text-center">
|
||||
Veuillez d'abord configurer Cloudinary ci-dessus
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* API externe */}
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Plug className="w-5 h-5" />
|
||||
API externe
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Clé d'accès pour créer et programmer des publications via l'API externe
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-5 pt-0">
|
||||
{hasExistingExternalApiConfig && (
|
||||
<div className="rounded-lg bg-muted p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Key className="w-4 h-4" />
|
||||
<span>Clé active</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteExternalApiMutation.mutate()}
|
||||
disabled={deleteExternalApiMutation.isPending}
|
||||
>
|
||||
{deleteExternalApiMutation.isPending ? "..." : "Révoquer"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="externalApiKeyMobile">
|
||||
{hasExistingExternalApiConfig ? "Nouvelle clé (remplace l'actuelle)" : "Clé API"}
|
||||
</Label>
|
||||
<Input
|
||||
id="externalApiKeyMobile"
|
||||
type="password"
|
||||
value={externalApiKey}
|
||||
onChange={(e) => setExternalApiKey(e.target.value)}
|
||||
placeholder={hasExistingExternalApiConfig ? "Nouvelle clé..." : "Entrez une clé sécurisée"}
|
||||
className="min-h-[48px]"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Header <code className="bg-muted px-1 rounded">X-API-Key</code> dans vos requêtes
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => saveExternalApiMutation.mutate()}
|
||||
disabled={saveExternalApiMutation.isPending || !externalApiKey.trim()}
|
||||
className="w-full min-h-[48px]"
|
||||
>
|
||||
{saveExternalApiMutation.isPending ? "Enregistrement..." : hasExistingExternalApiConfig ? "Remplacer la clé" : "Enregistrer la clé"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Database, Download, Play } from "lucide-react";
|
||||
|
||||
export default function SqlAdminMobile() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const [sqlQuery, setSqlQuery] = useState("");
|
||||
const [queryResult, setQueryResult] = useState<any>(null);
|
||||
|
||||
const { data: tablesData } = useQuery({
|
||||
queryKey: ["/api/sql/tables"],
|
||||
});
|
||||
|
||||
const executeSqlMutation = useMutation({
|
||||
mutationFn: async (query: string) => {
|
||||
const res = await apiRequest("POST", "/api/sql/execute", { query });
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setQueryResult(data);
|
||||
if (data.success) {
|
||||
toast({
|
||||
title: "Requête exécutée",
|
||||
description: "La requête SQL a été exécutée avec succès",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Erreur SQL",
|
||||
description: error.message || "Erreur lors de l'exécution de la requête",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleExecuteQuery = () => {
|
||||
if (!sqlQuery.trim()) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Erreur",
|
||||
description: "Veuillez entrer une requête SQL",
|
||||
});
|
||||
return;
|
||||
}
|
||||
executeSqlMutation.mutate(sqlQuery);
|
||||
};
|
||||
|
||||
const handleDownloadResult = () => {
|
||||
if (!queryResult?.result) return;
|
||||
|
||||
const json = JSON.stringify(queryResult.result, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `sql-result-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const insertTable = (tableName: string) => {
|
||||
setSqlQuery(`SELECT * FROM ${tableName} LIMIT 10;`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
{/* MOBILE: Single column layout */}
|
||||
<div className="p-4 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Database className="w-7 h-7" />
|
||||
Administration SQL
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Exécutez des requêtes SQL et gérez votre base de données
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tables disponibles */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="text-base">Tables disponibles</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Cliquez pour insérer dans la requête
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5 pt-0">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(tablesData as any)?.tables?.map((table: any) => (
|
||||
<Button
|
||||
key={table.tablename}
|
||||
variant="outline"
|
||||
className="justify-start text-left min-h-[44px] text-xs"
|
||||
onClick={() => insertTable(table.tablename)}
|
||||
data-testid={`button-table-${table.tablename}`}
|
||||
>
|
||||
<Database className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span className="truncate">{table.tablename}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Éditeur SQL */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="text-base">Éditeur SQL</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Entrez votre requête SQL ci-dessous
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-5 pt-0">
|
||||
<Textarea
|
||||
placeholder="SELECT * FROM users LIMIT 10;"
|
||||
value={sqlQuery}
|
||||
onChange={(e) => setSqlQuery(e.target.value)}
|
||||
className="font-mono min-h-[150px] text-sm"
|
||||
data-testid="textarea-sql-query"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleExecuteQuery}
|
||||
disabled={executeSqlMutation.isPending}
|
||||
className="w-full min-h-[48px]"
|
||||
data-testid="button-execute-sql"
|
||||
>
|
||||
<Play className="w-5 h-5 mr-2" />
|
||||
{executeSqlMutation.isPending ? "Exécution..." : "Exécuter"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSqlQuery("")}
|
||||
className="w-full min-h-[48px]"
|
||||
data-testid="button-clear-sql"
|
||||
>
|
||||
Effacer
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Résultats */}
|
||||
{queryResult && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">Résultats</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{queryResult.success ? "Requête exécutée avec succès" : "Erreur lors de l'exécution"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{queryResult.success && queryResult.result && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadResult}
|
||||
className="w-full min-h-[44px]"
|
||||
data-testid="button-download-result"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Télécharger JSON
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5 pt-0">
|
||||
{queryResult.success ? (
|
||||
<div className="overflow-x-auto">
|
||||
{Array.isArray(queryResult.result) && queryResult.result.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{queryResult.result.map((row: any, idx: number) => (
|
||||
<div key={idx} className="p-3 rounded-lg border bg-card space-y-2">
|
||||
<div className="text-xs font-semibold text-muted-foreground">Ligne {idx + 1}</div>
|
||||
{Object.entries(row).map(([key, value]: [string, any]) => (
|
||||
<div key={key} className="flex flex-col gap-1">
|
||||
<div className="text-xs font-medium">{key}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground break-all">
|
||||
{value === null ? (
|
||||
<span className="italic">null</span>
|
||||
) : typeof value === 'object' ? (
|
||||
JSON.stringify(value)
|
||||
) : (
|
||||
String(value)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="bg-muted p-4 rounded-lg overflow-auto max-h-96 text-xs">
|
||||
<code>{JSON.stringify(queryResult.result, null, 2)}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-destructive/10 border border-destructive/20 text-destructive p-4 rounded-lg">
|
||||
<p className="font-semibold text-sm">Erreur :</p>
|
||||
<p className="font-mono text-xs mt-2 break-all">{queryResult.error}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,589 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Users, UserPlus, Pencil, Trash2, Shield, User as UserIcon, Settings } from "lucide-react";
|
||||
|
||||
type UserData = {
|
||||
id: string;
|
||||
username: string;
|
||||
role: "admin" | "user";
|
||||
};
|
||||
|
||||
type SocialPage = {
|
||||
id: string;
|
||||
pageName: string;
|
||||
platform: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
type UserPagePermission = {
|
||||
id: string;
|
||||
userId: string;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export default function UsersAdminMobile() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<"admin" | "user">("user");
|
||||
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<UserData | null>(null);
|
||||
const [editUsername, setEditUsername] = useState("");
|
||||
const [editPassword, setEditPassword] = useState("");
|
||||
const [editRole, setEditRole] = useState<"admin" | "user">("user");
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [userToDelete, setUserToDelete] = useState<UserData | null>(null);
|
||||
|
||||
const [permissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
|
||||
const [permissionsUser, setPermissionsUser] = useState<UserData | null>(null);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
|
||||
const { data: users, isLoading } = useQuery<UserData[]>({
|
||||
queryKey: ["/api/users"],
|
||||
});
|
||||
|
||||
const { data: allPages } = useQuery<SocialPage[]>({
|
||||
queryKey: ["/api/pages"],
|
||||
});
|
||||
|
||||
const { data: userPermissions } = useQuery<UserPagePermission[]>({
|
||||
queryKey: ["/api/users", permissionsUser?.id, "page-permissions"],
|
||||
enabled: !!permissionsUser && permissionsDialogOpen,
|
||||
});
|
||||
|
||||
const createUserMutation = useMutation({
|
||||
mutationFn: async (userData: { username: string; password: string; role: string }) => {
|
||||
const res = await apiRequest("POST", "/api/users", userData);
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast({
|
||||
title: "Utilisateur créé",
|
||||
description: `L'utilisateur ${data.username} a été créé avec succès`,
|
||||
});
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setRole("user");
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la création de l'utilisateur",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateUserMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: any }) => {
|
||||
const res = await apiRequest("PATCH", `/api/users/${id}`, data);
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast({
|
||||
title: "Utilisateur modifié",
|
||||
description: `L'utilisateur ${data.username} a été modifié avec succès`,
|
||||
});
|
||||
setEditDialogOpen(false);
|
||||
setEditingUser(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la modification de l'utilisateur",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteUserMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await apiRequest("DELETE", `/api/users/${id}`, {});
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Utilisateur supprimé",
|
||||
description: "L'utilisateur a été supprimé avec succès",
|
||||
});
|
||||
setDeleteDialogOpen(false);
|
||||
setUserToDelete(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la suppression de l'utilisateur",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updatePermissionsMutation = useMutation({
|
||||
mutationFn: async ({ userId, pageIds }: { userId: string; pageIds: string[] }) => {
|
||||
const res = await apiRequest("POST", `/api/users/${userId}/page-permissions`, { pageIds });
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Permissions mises à jour",
|
||||
description: "Les permissions de l'utilisateur ont été mises à jour avec succès",
|
||||
});
|
||||
setPermissionsDialogOpen(false);
|
||||
setPermissionsUser(null);
|
||||
setSelectedPageIds([]);
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la mise à jour des permissions",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!username || !password) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Erreur",
|
||||
description: "Veuillez remplir tous les champs",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 4) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Erreur",
|
||||
description: "Le mot de passe doit contenir au moins 4 caractères",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createUserMutation.mutate({ username, password, role });
|
||||
};
|
||||
|
||||
const handleEditClick = (user: UserData) => {
|
||||
setEditingUser(user);
|
||||
setEditUsername(user.username);
|
||||
setEditPassword("");
|
||||
setEditRole(user.role);
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSubmit = () => {
|
||||
if (!editingUser) return;
|
||||
|
||||
const updateData: any = {};
|
||||
|
||||
if (editUsername !== editingUser.username) {
|
||||
updateData.username = editUsername;
|
||||
}
|
||||
|
||||
if (editPassword) {
|
||||
updateData.password = editPassword;
|
||||
}
|
||||
|
||||
if (editRole !== editingUser.role) {
|
||||
updateData.role = editRole;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Erreur",
|
||||
description: "Aucune modification détectée",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateUserMutation.mutate({ id: editingUser.id, data: updateData });
|
||||
};
|
||||
|
||||
const handleDeleteClick = (user: UserData) => {
|
||||
setUserToDelete(user);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (userToDelete) {
|
||||
deleteUserMutation.mutate(userToDelete.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePermissionsClick = (user: UserData) => {
|
||||
setPermissionsUser(user);
|
||||
setPermissionsDialogOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (userPermissions) {
|
||||
setSelectedPageIds(userPermissions.map(p => p.pageId));
|
||||
}
|
||||
}, [userPermissions]);
|
||||
|
||||
const handlePermissionsSubmit = () => {
|
||||
if (permissionsUser) {
|
||||
updatePermissionsMutation.mutate({
|
||||
userId: permissionsUser.id,
|
||||
pageIds: selectedPageIds,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const togglePagePermission = (pageId: string) => {
|
||||
setSelectedPageIds(prev => {
|
||||
if (prev.includes(pageId)) {
|
||||
return prev.filter(id => id !== pageId);
|
||||
} else {
|
||||
return [...prev, pageId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
{/* MOBILE: Single column layout */}
|
||||
<div className="p-4 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Users className="w-7 h-7" />
|
||||
Gestion des utilisateurs
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Créez et gérez les utilisateurs de l'application
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Formulaire de création */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<UserPlus className="w-5 h-5" />
|
||||
Créer un utilisateur
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Ajouter un nouvel utilisateur
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5 pt-0">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-sm">Nom d'utilisateur</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="utilisateur123"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="min-h-[48px]"
|
||||
data-testid="input-new-username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm">Mot de passe</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="min-h-[48px]"
|
||||
data-testid="input-new-password"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Minimum 4 caractères
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role" className="text-sm">Rôle</Label>
|
||||
<Select value={role} onValueChange={(value: "admin" | "user") => setRole(value)}>
|
||||
<SelectTrigger id="role" className="min-h-[48px]" data-testid="select-user-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">Utilisateur</SelectItem>
|
||||
<SelectItem value="admin">Administrateur</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full min-h-[48px]"
|
||||
disabled={createUserMutation.isPending}
|
||||
data-testid="button-create-user"
|
||||
>
|
||||
{createUserMutation.isPending ? "Création..." : "Créer"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Liste des utilisateurs */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="text-base">Liste des utilisateurs</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{users?.length || 0} utilisateur(s) enregistré(s)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5 pt-0">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="text-muted-foreground mt-4 text-sm">Chargement...</p>
|
||||
</div>
|
||||
) : users && users.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="p-4 rounded-lg border bg-card space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{user.role === "admin" ? (
|
||||
<Shield className="w-5 h-5 text-primary flex-shrink-0" />
|
||||
) : (
|
||||
<UserIcon className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">{user.username}</div>
|
||||
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="mt-1">
|
||||
{user.role === "admin" ? "Administrateur" : "Utilisateur"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{user.role === "user" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePermissionsClick(user)}
|
||||
className="w-full min-h-[44px]"
|
||||
data-testid={`button-permissions-user-${user.id}`}
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Permissions
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditClick(user)}
|
||||
className="flex-1 min-h-[44px]"
|
||||
data-testid={`button-edit-user-${user.id}`}
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteClick(user)}
|
||||
className="flex-1 min-h-[44px]"
|
||||
data-testid={`button-delete-user-${user.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground text-sm">Aucun utilisateur trouvé</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Dialog de modification */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent className="w-[95vw] max-w-md mx-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Modifiez les informations de l'utilisateur. Laissez le mot de passe vide pour ne pas le changer.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-username" className="text-sm">Nom d'utilisateur</Label>
|
||||
<Input
|
||||
id="edit-username"
|
||||
value={editUsername}
|
||||
onChange={(e) => setEditUsername(e.target.value)}
|
||||
className="min-h-[48px]"
|
||||
data-testid="input-edit-username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-password" className="text-sm">Nouveau mot de passe (optionnel)</Label>
|
||||
<Input
|
||||
id="edit-password"
|
||||
type="password"
|
||||
placeholder="Laisser vide pour ne pas changer"
|
||||
value={editPassword}
|
||||
onChange={(e) => setEditPassword(e.target.value)}
|
||||
className="min-h-[48px]"
|
||||
data-testid="input-edit-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-role" className="text-sm">Rôle</Label>
|
||||
<Select value={editRole} onValueChange={(value: "admin" | "user") => setEditRole(value)}>
|
||||
<SelectTrigger id="edit-role" className="min-h-[48px]" data-testid="select-edit-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">Utilisateur</SelectItem>
|
||||
<SelectItem value="admin">Administrateur</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleEditSubmit}
|
||||
disabled={updateUserMutation.isPending}
|
||||
className="w-full min-h-[48px]"
|
||||
data-testid="button-save-edit"
|
||||
>
|
||||
{updateUserMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)} className="w-full min-h-[48px]">
|
||||
Annuler
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog de confirmation de suppression */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent className="w-[95vw] max-w-md mx-auto">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmer la suppression</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-xs">
|
||||
Êtes-vous sûr de vouloir supprimer l'utilisateur <strong>{userToDelete?.username}</strong> ?
|
||||
Cette action est irréversible.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-col gap-2">
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 w-full min-h-[48px]"
|
||||
data-testid="button-confirm-delete"
|
||||
>
|
||||
{deleteUserMutation.isPending ? "Suppression..." : "Supprimer"}
|
||||
</AlertDialogAction>
|
||||
<AlertDialogCancel className="w-full min-h-[48px] mt-0">Annuler</AlertDialogCancel>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Dialog de gestion des permissions */}
|
||||
<Dialog open={permissionsDialogOpen} onOpenChange={setPermissionsDialogOpen}>
|
||||
<DialogContent className="w-[95vw] max-w-2xl mx-auto max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Gérer les permissions</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Sélectionnez les pages auxquelles l'utilisateur <strong>{permissionsUser?.username}</strong> peut accéder
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{allPages && allPages.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{allPages.map((page) => (
|
||||
<div key={page.id} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-accent touch-manipulation">
|
||||
<Checkbox
|
||||
id={`page-${page.id}`}
|
||||
checked={selectedPageIds.includes(page.id)}
|
||||
onCheckedChange={() => togglePagePermission(page.id)}
|
||||
className="w-5 h-5"
|
||||
data-testid={`checkbox-page-${page.id}`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`page-${page.id}`}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
<div className="font-medium text-sm">{page.pageName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{page.platform === "facebook" ? "Facebook" : "Instagram"}
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
Aucune page disponible
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="flex-col gap-2">
|
||||
<Button
|
||||
onClick={handlePermissionsSubmit}
|
||||
disabled={updatePermissionsMutation.isPending}
|
||||
className="w-full min-h-[48px]"
|
||||
data-testid="button-save-permissions"
|
||||
>
|
||||
{updatePermissionsMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setPermissionsDialogOpen(false)} className="w-full min-h-[48px]">
|
||||
Annuler
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,23 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { Compass } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LogoMark } from "@/components/brand/logo";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex mb-4 gap-2">
|
||||
<AlertCircle className="h-8 w-8 text-red-500" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Did you forget to add the page to the router?
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex min-h-screen w-full flex-col items-center justify-center bg-background p-6 text-center">
|
||||
<LogoMark size={44} />
|
||||
<div className="mt-8 flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Compass className="h-6 w-6" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-primary">Erreur 404</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight">Page introuvable</h1>
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||
La page que vous cherchez n'existe pas ou a été déplacée.
|
||||
</p>
|
||||
<Button asChild className="mt-6">
|
||||
<Link href="/">Retour au tableau de bord</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { CameraRecorder } from '@/components/camera-recorder';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function TestCameraPage() {
|
||||
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
||||
const [showCamera, setShowCamera] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-gray-900 min-h-screen text-white">
|
||||
<h1 className="text-3xl font-bold mb-8">Test Caméra Isolé</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Button onClick={() => setShowCamera(true)}>Ouvrir Caméra (react-webcam)</Button>
|
||||
|
||||
{capturedFile && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold mb-2">Fichier Capturé :</h2>
|
||||
<p>Nom: {capturedFile.name}</p>
|
||||
<p>Taille: {(capturedFile.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
<p>Type: {capturedFile.type}</p>
|
||||
<video controls src={URL.createObjectURL(capturedFile)} className="mt-4 max-w-sm border border-white/20 rounded" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCamera && (
|
||||
<CameraRecorder
|
||||
onCapture={(file) => {
|
||||
console.log("Captured:", file);
|
||||
setCapturedFile(file);
|
||||
setShowCamera(false);
|
||||
}}
|
||||
onCancel={() => setShowCamera(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user