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:
Claude committed 2026-10-06 12:44:11 +00:00
1 parent 01315bf281
commit 155c3385c1
55 files changed
+1591 -6331

No files matched your search

+14 -2
View File
@@ -4,11 +4,23 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>Social Flow - Automatisation des réseaux sociaux</title>
<meta name="description" content="Automatisez vos publications Facebook et Instagram avec Social Flow. Génération de contenu par IA, planification et publication automatique." />
<meta name="description" content="Automatisez vos publications Facebook, Instagram et TikTok avec Social Flow. Génération de contenu par IA, planification et publication automatique." />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#4F46E5" />
<script>
// Applique le thème avant le premier rendu pour éviter un flash.
(function () {
try {
var t = localStorage.getItem("theme") || "system";
var dark = t === "dark" || (t === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("dark");
document.documentElement.style.colorScheme = dark ? "dark" : "light";
} catch (e) {}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Architects+Daughter&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Fira+Code:wght@300..700&family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Lora:ital,wght@0,400..700;1,400..700&family=Merriweather:ital,opsz,wght@0,18..144,300..900;1,18..144,300..900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Outfit:wght@100..900&family=Oxanium:wght@200..800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100..900;1,100..900&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
+9 -17
View File
@@ -1,21 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
<stop offset="100%" style="stop-color:#a855f7;stop-opacity:1" />
<linearGradient id="g" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#4F46E5"/>
<stop offset="0.6" stop-color="#7C3AED"/>
<stop offset="1" stop-color="#F97362"/>
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="50" cy="50" r="48" fill="url(#grad1)"/>
<!-- Social network icon - simplified nodes and connections -->
<circle cx="30" cy="35" r="8" fill="white" opacity="0.9"/>
<circle cx="70" cy="35" r="8" fill="white" opacity="0.9"/>
<circle cx="50" cy="65" r="8" fill="white" opacity="0.9"/>
<!-- Connecting lines -->
<line x1="30" y1="35" x2="50" y2="65" stroke="white" stroke-width="3" opacity="0.7"/>
<line x1="70" y1="35" x2="50" y2="65" stroke="white" stroke-width="3" opacity="0.7"/>
<line x1="30" y1="35" x2="70" y2="35" stroke="white" stroke-width="3" opacity="0.7"/>
<rect width="32" height="32" rx="9" fill="url(#g)"/>
<path d="M7 12.5c2.4-3.6 5.1-3.6 7.5 0s5.1 3.6 7.5 0" stroke="#fff" stroke-opacity="0.55" stroke-width="2.4" stroke-linecap="round"/>
<path d="M7 19.5c2.4-3.6 5.1-3.6 7.5 0s5.1 3.6 7.5 0" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
<circle cx="25.2" cy="16" r="1.9" fill="#fff"/>
</svg>

Before

Width:  |  Height:  |  Size: 952 B

After

Width:  |  Height:  |  Size: 705 B

+75 -119
View File
@@ -1,99 +1,58 @@
import { Switch, Route, Redirect, useLocation } from "wouter";
import { useEffect } from "react";
import { Switch, Route, useLocation, Link } from "wouter";
import { lazy, Suspense, useEffect, type ComponentType } from "react";
import { queryClient } from "./lib/queryClient";
import { QueryClientProvider, useQuery } from "@tanstack/react-query";
import { QueryClientProvider } from "@tanstack/react-query";
import { ShieldAlert } from "lucide-react";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { createResponsiveRoute } from "@/components/responsive-route";
import { Button } from "@/components/ui/button";
import { ThemeProvider } from "@/components/theme-provider";
import { AppShell, Page } from "@/components/layout/app-shell";
import { EmptyState } from "@/components/empty-state";
import { LogoMark } from "@/components/brand/logo";
import { useSession } from "@/hooks/use-session";
import NotFound from "@/pages/not-found";
import Login from "@/pages/login";
import TestCamera from "@/pages/test-camera";
// Pages chargées à la demande : une seule version responsive par écran.
const Dashboard = lazy(() => import("@/pages/dashboard"));
const NewPost = lazy(() => import("@/pages/new-post"));
const NewReel = lazy(() => import("@/pages/new-reel"));
const ScheduleReel = lazy(() => import("@/pages/schedule-reel"));
const Calendar = lazy(() => import("@/pages/calendar"));
const Media = lazy(() => import("@/pages/media"));
const ImageEditor = lazy(() => import("@/pages/image-editor"));
const PagesManagement = lazy(() => import("@/pages/pages"));
const AI = lazy(() => import("@/pages/ai"));
const History = lazy(() => import("@/pages/history"));
const Settings = lazy(() => import("@/pages/settings"));
const SqlAdmin = lazy(() => import("@/pages/sql"));
const AudioAdmin = lazy(() => import("@/pages/audio-admin"));
const UsersAdmin = lazy(() => import("@/pages/users-admin"));
const Analytics = lazy(() => import("@/pages/analytics"));
// Create responsive routes with lazy loading for optimal performance
const History = createResponsiveRoute(
() => import("@/pages/history"),
() => import("@/pages/mobile/history")
);
const Dashboard = createResponsiveRoute(
() => import("@/pages/dashboard"),
() => import("@/pages/mobile/dashboard")
);
function FullScreenLoader() {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<LogoMark size={44} className="animate-pulse" />
<p className="text-sm text-muted-foreground">Chargement…</p>
</div>
</div>
);
}
const NewPost = createResponsiveRoute(
() => import("@/pages/new-post"),
() => import("@/pages/mobile/new-post")
);
function PageLoader() {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
const Calendar = createResponsiveRoute(
() => import("@/pages/calendar"),
() => import("@/pages/mobile/calendar")
);
const Media = createResponsiveRoute(
() => import("@/pages/media"),
() => import("@/pages/mobile/media")
);
const ImageEditor = createResponsiveRoute(
() => import("@/pages/image-editor"),
() => import("@/pages/mobile/image-editor")
);
const PagesManagement = createResponsiveRoute(
() => import("@/pages/pages"),
() => import("@/pages/mobile/pages")
);
const AI = createResponsiveRoute(
() => import("@/pages/ai"),
() => import("@/pages/mobile/ai")
);
const Settings = createResponsiveRoute(
() => import("@/pages/settings"),
() => import("@/pages/mobile/settings")
);
const SqlAdmin = createResponsiveRoute(
() => import("@/pages/sql"),
() => import("@/pages/mobile/sql")
);
const AudioAdmin = createResponsiveRoute(
() => import("@/pages/audio-admin"),
() => import("@/pages/mobile/audio-admin")
);
const UsersAdmin = createResponsiveRoute(
() => import("@/pages/users-admin"),
() => import("@/pages/mobile/users-admin")
);
const Analytics = createResponsiveRoute(
() => import("@/pages/AnalyticsPage"),
() => import("@/pages/mobile/analytics")
);
// NewReel uses desktop version for now (mobile can be added later)
const NewReel = createResponsiveRoute(
() => import("@/pages/new-reel"),
() => import("@/pages/mobile/new-reel") // Uses same component for now
);
// Reel déjà monté : même page responsive sur mobile et desktop
const ScheduleReel = createResponsiveRoute(
() => import("@/pages/schedule-reel"),
() => import("@/pages/schedule-reel")
);
function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) {
function ProtectedRoute({ component: Component, adminOnly = false }: { component: ComponentType; adminOnly?: boolean }) {
const [, setLocation] = useLocation();
const { data: session, isLoading } = useQuery({
queryKey: ["/api/auth/session"],
retry: false,
});
const { session, isAdmin, isLoading } = useSession();
useEffect(() => {
if (!isLoading && !session) {
@@ -101,33 +60,31 @@ function ProtectedRoute({ component: Component, adminOnly = false }: { component
}
}, [isLoading, session, setLocation]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-background">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Chargement...</p>
</div>
</div>
);
}
if (isLoading) return <FullScreenLoader />;
if (!session) return null;
if (!session) {
return null;
}
if (adminOnly && (session as any).role !== "admin") {
return (
<div className="flex items-center justify-center h-screen bg-background">
<div className="text-center">
<h1 className="text-2xl font-bold mb-2">Accès refusé</h1>
<p className="text-muted-foreground">Cette page est réservée aux administrateurs.</p>
</div>
</div>
);
}
return <Component />;
return (
<AppShell>
{adminOnly && !isAdmin ? (
<Page width="narrow">
<EmptyState
icon={ShieldAlert}
title="Accès réservé aux administrateurs"
description="Demandez à un administrateur de vous donner accès à cette page."
action={
<Button asChild variant="outline">
<Link href="/">Retour au tableau de bord</Link>
</Button>
}
/>
</Page>
) : (
<Suspense fallback={<PageLoader />}>
<Component />
</Suspense>
)}
</AppShell>
);
}
function Router() {
@@ -149,7 +106,6 @@ function Router() {
<Route path="/audio-admin">{() => <ProtectedRoute component={AudioAdmin} adminOnly />}</Route>
<Route path="/users">{() => <ProtectedRoute component={UsersAdmin} adminOnly />}</Route>
<Route path="/analytics">{() => <ProtectedRoute component={Analytics} />}</Route>
<Route path="/test-camera" component={TestCamera} />
<Route component={NotFound} />
</Switch>
);
@@ -157,14 +113,14 @@ function Router() {
function App() {
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<div>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Router />
</div>
</TooltipProvider>
</QueryClientProvider>
</TooltipProvider>
</QueryClientProvider>
</ThemeProvider>
);
}
+75 -96
View File
@@ -4,7 +4,8 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useMutation, useQuery } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Bot, User, Lightbulb, History, Zap, Sparkles, Loader2 } from "lucide-react";
import { Bot, User, Sparkles, Loader2, Copy, RotateCcw } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import { ModelCombobox } from "@/components/model-combobox";
@@ -90,33 +91,20 @@ export default function AiChat() {
setInput("");
};
const copyVariant = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast({ title: "Texte copié", description: "Collez-le dans votre publication." });
} catch {
toast({ title: "Copie impossible", description: "Sélectionnez le texte manuellement.", variant: "destructive" });
}
};
return (
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg h-full flex flex-col">
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg">
<Sparkles className="text-white w-6 h-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">Assistant IA</h3>
<p className="text-sm text-muted-foreground">Génération de contenu intelligent</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setMessages([messages[0]])}
className="rounded-xl"
data-testid="button-new-chat"
>
<Sparkles className="w-4 h-4 mr-2" />
Nouveau chat
</Button>
</div>
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-foreground">Modèle IA:</label>
<div className="flex h-[calc(100dvh-15rem)] min-h-[480px] flex-col overflow-hidden rounded-xl border bg-card shadow-soft lg:h-[calc(100vh-13rem)]">
<div className="flex flex-col gap-3 border-b p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-sm text-muted-foreground">Modèle</span>
<ModelCombobox
models={availableModels}
value={selectedModel}
@@ -124,78 +112,91 @@ export default function AiChat() {
placeholder="Sélectionner un modèle"
isLoading={modelsLoading}
disabled={modelsLoading}
className="w-[300px] rounded-xl"
className="w-full sm:w-[300px]"
testId="select-ai-model"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setMessages([messages[0]])}
data-testid="button-new-chat"
>
<RotateCcw className="h-4 w-4" />
Nouvelle conversation
</Button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6 bg-muted/20">
<div className="flex-1 space-y-5 overflow-y-auto bg-muted/30 p-4 sm:p-6">
{messages.map((message, index) => (
<div
key={index}
className={`flex gap-4 chat-message ${message.role === "user" ? "justify-end" : ""}`}
>
<div key={index} className={`flex gap-3 ${message.role === "user" ? "justify-end" : ""}`}>
{message.role === "assistant" && (
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-secondary flex-shrink-0 flex items-center justify-center shadow-md">
<Bot className="text-white w-5 h-5" />
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Bot className="h-4 w-4" />
</div>
)}
<div className={`flex-1 ${message.role === "user" ? "max-w-md ml-auto" : "max-w-2xl"}`}>
<div className={`
rounded-2xl p-6 shadow-md
${message.role === "user"
? "bg-gradient-to-br from-primary to-secondary text-white ml-auto"
: "bg-card border border-border/50"
<div className={message.role === "user" ? "max-w-[85%] sm:max-w-md" : "max-w-full flex-1 sm:max-w-2xl"}>
<div
className={
message.role === "user"
? "rounded-2xl rounded-tr-sm bg-primary px-4 py-3 text-primary-foreground"
: "rounded-2xl rounded-tl-sm border bg-card px-4 py-3"
}
`}>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
>
<p className="whitespace-pre-wrap text-sm leading-relaxed">{message.content}</p>
{message.variants && (
<div className="space-y-4 mt-6">
<div className="mt-4 space-y-3">
{message.variants.map((variant, vIndex) => (
<div
<button
type="button"
key={vIndex}
className="p-5 rounded-xl bg-muted/50 border border-border/50 cursor-pointer hover:bg-muted hover:shadow-md transition-all"
onClick={() => copyVariant(variant.text)}
className="group block w-full rounded-lg border bg-muted/40 p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent"
data-testid={`variant-${vIndex}`}
>
<div className="flex items-center justify-between mb-3">
<span className={`
inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold
${vIndex === 0 ? "bg-gradient-to-r from-primary to-secondary text-white" : "bg-secondary/20 text-secondary"}
`}>
{variant.variant}
<div className="mb-2 flex items-center justify-between gap-2">
<Badge variant={vIndex === 0 ? "default" : "muted"}>{variant.variant}</Badge>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
{variant.characterCount} caractères
<Copy className="h-3.5 w-3.5 opacity-60 group-hover:opacity-100" />
</span>
<span className="text-xs text-muted-foreground font-medium">{variant.characterCount} caractères</span>
</div>
<p className="text-sm text-foreground leading-relaxed">{variant.text}</p>
</div>
<p className="text-sm leading-relaxed text-foreground">{variant.text}</p>
</button>
))}
<p className="text-xs text-muted-foreground">Cliquez sur une proposition pour la copier.</p>
</div>
)}
</div>
<span className="text-xs text-muted-foreground mt-2 inline-block">
{message.role === "user" ? "Maintenant" : "Il y a quelques secondes"}
</span>
</div>
{message.role === "user" && (
<div className="w-10 h-10 rounded-xl bg-secondary/20 flex-shrink-0 flex items-center justify-center">
<User className="text-secondary w-5 h-5" />
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<User className="h-4 w-4" />
</div>
)}
</div>
))}
{generateMutation.isPending && (
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
L'IA rédige vos propositions…
</div>
)}
</div>
<div className="p-6 border-t border-border/50 bg-card space-y-4">
<div className="flex gap-4">
<div className="border-t bg-card p-3 sm:p-4">
<div className="flex items-end gap-2">
<Textarea
placeholder="Décrivez votre produit (nom, prix, caractéristiques)..."
placeholder="Décrivez votre produit (nom, prix, caractéristiques)…"
value={input}
onChange={(e) => setInput(e.target.value)}
className="flex-1 min-h-[80px] rounded-xl bg-muted/30 border-border/50 focus:border-primary/50"
className="max-h-40 min-h-[48px] flex-1 resize-none"
rows={2}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@@ -204,42 +205,20 @@ export default function AiChat() {
}}
data-testid="input-product-info"
/>
<Button
<Button
onClick={handleSubmit}
disabled={generateMutation.isPending || !input.trim()}
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90 rounded-xl px-6"
variant="brand"
className="h-12"
data-testid="button-generate"
>
<Sparkles className="w-4 h-4 mr-2" />
{generateMutation.isPending ? "Génération..." : "Générer"}
{generateMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
<span className="hidden sm:inline">Générer</span>
</Button>
</div>
<div className="flex items-center gap-3 flex-wrap">
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => toast({ title: "Bientôt disponible", description: "Les suggestions de produits seront disponibles prochainement" })}
data-testid="button-suggestions"
>
<Lightbulb className="w-4 h-4 mr-2" />
Suggestions
</Button>
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => toast({ title: "Bientôt disponible", description: "L'historique des générations sera disponible prochainement" })}
data-testid="button-history"
>
<History className="w-4 h-4 mr-2" />
Historique
</Button>
<div className="ml-auto text-xs text-muted-foreground flex items-center gap-2">
<Zap className="w-4 h-4 text-success" />
<span>Propulsé par OpenRouter</span>
</div>
</div>
<p className="mt-2 hidden text-xs text-muted-foreground sm:block">
Entrée pour envoyer · Maj + Entrée pour aller à la ligne
</p>
</div>
</div>
);
+56
View File
@@ -0,0 +1,56 @@
import { useId } from "react";
import { cn } from "@/lib/utils";
interface LogoMarkProps {
size?: number;
className?: string;
}
/** Pictogramme Social Flow : deux ondes qui « coulent » vers un point de diffusion. */
export function LogoMark({ size = 36, className }: LogoMarkProps) {
const id = useId().replace(/:/g, "");
return (
<svg
width={size}
height={size}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn("shrink-0", className)}
aria-hidden="true"
>
<defs>
<linearGradient id={`sf-${id}`} x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#4F46E5" />
<stop offset="0.6" stopColor="#7C3AED" />
<stop offset="1" stopColor="#F97362" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="9" fill={`url(#sf-${id})`} />
<path d="M7 12.5c2.4-3.6 5.1-3.6 7.5 0s5.1 3.6 7.5 0" stroke="#fff" strokeOpacity="0.55" strokeWidth="2.4" strokeLinecap="round" />
<path d="M7 19.5c2.4-3.6 5.1-3.6 7.5 0s5.1 3.6 7.5 0" stroke="#fff" strokeWidth="2.6" strokeLinecap="round" />
<circle cx="25.2" cy="16" r="1.9" fill="#fff" />
</svg>
);
}
interface LogoProps extends LogoMarkProps {
withText?: boolean;
subtitle?: boolean;
}
export function Logo({ size = 36, withText = true, subtitle = true, className }: LogoProps) {
return (
<div className={cn("flex items-center gap-3 min-w-0", className)}>
<LogoMark size={size} />
{withText && (
<div className="min-w-0 leading-tight">
<p className="font-semibold tracking-tight text-foreground text-[17px]">
Social<span className="text-primary">Flow</span>
</p>
{subtitle && <p className="text-xs text-muted-foreground truncate">Automatisation sociale</p>}
</div>
)}
</div>
);
}
-303
View File
@@ -1,303 +0,0 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import Webcam from 'react-webcam';
import { Camera, StopCircle, RefreshCw, CheckCircle, X, RotateCcw, Settings2, AlertCircle, Video } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/use-toast';
interface CameraRecorderProps {
onCapture: (file: File, options?: { stabilize?: boolean }) => void;
onCancel: () => void;
}
// Quality configurations
type VideoQuality = "4k" | "1080p" | "720p";
interface QualityConfig {
label: string;
width: number;
height: number;
bitrate: number; // in bps
}
const QUALITY_CONFIGS: Record<VideoQuality, QualityConfig> = {
"4k": { label: "4K Vertical", width: 2160, height: 3840, bitrate: 40000000 },
"1080p": { label: "Full HD (Reel)", width: 1080, height: 1920, bitrate: 12000000 }, // Align with 12Mbps max spec
"720p": { label: "HD Vertical", width: 720, height: 1280, bitrate: 8000000 },
};
export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
const webcamRef = useRef<Webcam>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const { toast } = useToast();
const [isRecording, setIsRecording] = useState(false);
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [timer, setTimer] = useState(0);
// Devices & Settings
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
const [facingMode, setFacingMode] = useState<"user" | "environment">("environment");
const [quality, setQuality] = useState<VideoQuality>('1080p');
const [showSettings, setShowSettings] = useState(false);
const [cameraError, setCameraError] = useState<string | null>(null);
const [stabilize, setStabilize] = useState(true); // Stabilisation serveur activée par défaut
// Fetch devices
const handleDevices = useCallback((mediaDevices: MediaDeviceInfo[]) => {
const videoDevices = mediaDevices.filter(({ kind }) => kind === "videoinput");
setDevices(videoDevices);
if (videoDevices.length > 0 && !selectedDeviceId) {
setSelectedDeviceId(videoDevices[0].deviceId);
}
}, [selectedDeviceId]);
useEffect(() => {
navigator.mediaDevices.enumerateDevices().then(handleDevices).catch(err => {
console.error("Error enumerating devices", err);
setCameraError("Erreur liste périphériques: " + err.message);
});
}, [handleDevices]);
// Timer
useEffect(() => {
let interval: NodeJS.Timeout;
if (isRecording) {
interval = setInterval(() => setTimer(t => t + 1), 1000);
} else {
setTimer(0);
}
return () => clearInterval(interval);
}, [isRecording]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Recording Logic
const startRecording = useCallback(() => {
if (!webcamRef.current || !webcamRef.current.stream) return;
chunksRef.current = [];
const stream = webcamRef.current.stream;
const mimeTypes = ['video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm'];
const mimeType = mimeTypes.find(type => MediaRecorder.isTypeSupported(type)) || '';
const currentConfig = QUALITY_CONFIGS[quality];
try {
const mediaRecorder = new MediaRecorder(stream, {
mimeType,
videoBitsPerSecond: currentConfig.bitrate // Bitrate dynamique selon qualité
});
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
mediaRecorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: mimeType || 'video/webm' });
setRecordedBlob(blob);
setPreviewUrl(URL.createObjectURL(blob));
};
mediaRecorder.start();
mediaRecorderRef.current = mediaRecorder;
setIsRecording(true);
} catch (e: any) {
console.error("Recording error", e);
toast({ title: "Erreur", description: "Impossible de lancer l'enregistrement: " + e.message, variant: "destructive" });
}
}, [webcamRef, toast]);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
}
}, [isRecording]);
const handleConfirm = () => {
if (recordedBlob) {
onCapture(
new File([recordedBlob], `capture-${Date.now()}.mp4`, { type: recordedBlob.type }),
{ stabilize }
);
}
};
const handleUserMediaError = useCallback((error: string | DOMException) => {
console.error("Webcam Error:", error);
const msg = typeof error === 'string' ? error : error.message;
setCameraError(msg);
}, []);
const toggleCamera = () => {
setFacingMode(prev => prev === "user" ? "environment" : "user");
setSelectedDeviceId('');
};
// Review Mode
if (recordedBlob && previewUrl) {
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col items-center justify-center p-4">
<video src={previewUrl} controls className="w-full max-h-[80vh] rounded-lg mb-8" autoPlay playsInline />
<div className="flex gap-4">
<Button variant="secondary" size="lg" onClick={() => { setRecordedBlob(null); setPreviewUrl(null); }} className="rounded-full px-8 py-6 text-lg">
<RotateCcw className="mr-2 h-5 w-5" /> Refaire
</Button>
<Button size="lg" onClick={handleConfirm} className="rounded-full px-8 py-6 text-lg bg-green-600 hover:bg-green-700">
<CheckCircle className="mr-2 h-5 w-5" /> Valider
</Button>
</div>
</div>
);
}
const config = QUALITY_CONFIGS[quality];
const videoConstraints = {
width: { ideal: config.width },
height: { ideal: config.height },
frameRate: { ideal: 30 }, // 30fps pour une capture fluide
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
facingMode: selectedDeviceId ? undefined : facingMode
};
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col">
{/* Header */}
<div className="absolute top-0 left-0 right-0 p-4 z-10 flex justify-between items-center bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
<div className="pointer-events-auto">
<Button variant="ghost" size="icon" onClick={onCancel} className="text-white bg-black/20 hover:bg-black/40">
<X className="h-6 w-6" />
</Button>
</div>
<div className="pointer-events-auto flex gap-2">
{isRecording ? (
<div className="bg-red-600 px-3 py-1 rounded-full text-white font-mono text-sm flex items-center gap-2 animate-pulse">
<div className="w-2 h-2 bg-white rounded-full" />
{formatTime(timer)}
</div>
) : (
<div className="flex gap-2">
<Button variant="outline" size="icon" onClick={toggleCamera} className="bg-black/30 border-white/20 text-white backdrop-blur">
<RefreshCw className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => setShowSettings(!showSettings)} className="bg-black/30 border-white/20 text-white backdrop-blur">
<Settings2 className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Réglages</span>
</Button>
</div>
)}
</div>
<div className="w-10"></div>
</div>
{/* Error Overlay */}
{cameraError && (
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center bg-black/90 p-6 text-center">
<AlertCircle className="w-16 h-16 text-red-500 mb-4" />
<h3 className="text-xl font-bold text-red-500 mb-2">Erreur Caméra</h3>
<p className="text-white mb-6">{cameraError}</p>
<p className="text-sm text-gray-400">Vérifiez vos permissions et que vous êtes bien en HTTPS (ou localhost).</p>
<Button onClick={() => window.location.reload()} variant="outline" className="mt-4 text-black">Recharger</Button>
</div>
)}
{/* Settings Overlay */}
{showSettings && !isRecording && (
<div className="absolute inset-0 z-20 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-[#1c1c1e] text-white p-6 rounded-2xl w-full max-w-sm border border-white/10 shadow-2xl">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-bold">Paramètres</h2>
<Button variant="ghost" size="icon" onClick={() => setShowSettings(false)}>
<X className="h-5 w-5" />
</Button>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-white/70 block mb-2">Caméra</label>
<Button variant="outline" className="w-full justify-start text-white border-white/20 hover:bg-white/10 mb-2" onClick={toggleCamera}>
<RefreshCw className="mr-2 h-4 w-4" />
Basculer Avant/Arrière
</Button>
<div className="space-y-1 max-h-32 overflow-y-auto">
{devices.map((device, idx) => (
<button
key={device.deviceId || idx}
onClick={() => setSelectedDeviceId(device.deviceId)}
className={`w-full text-left px-3 py-2 rounded text-sm truncate border transition-colors ${selectedDeviceId === device.deviceId ? 'bg-white text-black' : 'bg-transparent text-white border-white/20 hover:bg-white/10'}`}
>
{device.label || `Caméra ${idx + 1}`}
</button>
))}
</div>
</div>
<div>
<label className="text-sm font-medium text-white/70">Qualité</label>
<div className="grid grid-cols-3 gap-2 mt-1">
{(Object.keys(QUALITY_CONFIGS) as VideoQuality[]).map((q) => (
<button
key={q}
onClick={() => setQuality(q)}
className={`px-2 py-2 rounded text-sm border transition-colors ${quality === q ? 'bg-white text-black' : 'bg-transparent text-white border-white/20 hover:bg-white/10'}`}
>
{q.toUpperCase()}
</button>
))}
</div>
</div>
<div>
<label className="text-sm font-medium text-white/70 flex items-center justify-between">
<span>Stabilisation serveur</span>
<button
onClick={() => setStabilize(!stabilize)}
className={`relative w-12 h-6 rounded-full transition-colors ${stabilize ? 'bg-green-500' : 'bg-gray-600'}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${stabilize ? 'left-7' : 'left-1'}`} />
</button>
</label>
<p className="text-xs text-white/50 mt-1">Corrige les tremblements après capture (traitement serveur)</p>
</div>
</div>
<Button className="w-full mt-6 bg-white text-black hover:bg-gray-200" onClick={() => setShowSettings(false)}>OK</Button>
</div>
</div>
)}
{/* Webcam Feed */}
<div className="flex-1 bg-black flex items-center justify-center overflow-hidden relative">
<Webcam
audio={true}
ref={webcamRef}
videoConstraints={videoConstraints}
onUserMediaError={handleUserMediaError}
className="absolute w-full h-full object-cover"
muted // Local preview always muted to avoid feedback
/>
</div>
{/* Footer Controls */}
<div className="absolute bottom-0 left-0 right-0 p-8 pb-12 flex flex-col items-center justify-end bg-gradient-to-t from-black/80 to-transparent h-48 pointer-events-none">
<div className="pointer-events-auto">
<button
onClick={isRecording ? stopRecording : startRecording}
className="group relative flex items-center justify-center transition-all hover:scale-105 active:scale-95"
>
<div className={`w-20 h-20 rounded-full border-[6px] border-white flex items-center justify-center p-1 ${isRecording ? 'opacity-100' : ''}`}>
<div className={`transition-all duration-300 ${isRecording ? 'w-8 h-8 rounded-md bg-red-500' : 'w-full h-full rounded-full bg-red-500'}`} />
</div>
</button>
</div>
</div>
</div>
);
}
export default CameraRecorder;
+32
View File
@@ -0,0 +1,32 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description?: ReactNode;
action?: ReactNode;
className?: string;
compact?: boolean;
}
/** État vide homogène : icône, message et action suggérée. */
export function EmptyState({ icon: Icon, title, description, action, className, compact = false }: EmptyStateProps) {
return (
<div
className={cn(
"flex flex-col items-center justify-center rounded-xl border border-dashed bg-muted/30 text-center",
compact ? "px-4 py-8" : "px-6 py-14",
className,
)}
>
<div className={cn("mb-4 flex items-center justify-center rounded-full bg-primary/10 text-primary", compact ? "h-10 w-10" : "h-14 w-14")}>
<Icon className={compact ? "h-5 w-5" : "h-6 w-6"} />
</div>
<p className="font-medium text-foreground">{title}</p>
{description && <p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>}
{action && <div className="mt-5">{action}</div>}
</div>
);
}
@@ -0,0 +1,49 @@
import type { ReactNode } from "react";
import { Link, useLocation } from "wouter";
import Sidebar from "@/components/sidebar";
import { LogoMark } from "@/components/brand/logo";
import { MobileNav } from "@/components/layout/mobile-nav";
import { titleForPath } from "@/components/layout/nav-config";
import { cn } from "@/lib/utils";
/** Cadre de l'application connectée : sidebar (desktop), en-tête + barre basse (mobile). */
export function AppShell({ children }: { children: ReactNode }) {
const [location] = useLocation();
return (
<div className="flex min-h-screen bg-background">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b bg-card/90 px-4 backdrop-blur-lg lg:hidden">
<Link href="/" aria-label="Accueil">
<LogoMark size={30} />
</Link>
<p className="truncate font-semibold">{titleForPath(location)}</p>
</header>
<main className="flex-1 pb-24 lg:pb-0">{children}</main>
</div>
<MobileNav />
</div>
);
}
const WIDTHS = {
narrow: "max-w-3xl",
default: "max-w-6xl",
wide: "max-w-[1400px]",
} as const;
interface PageProps {
children: ReactNode;
width?: keyof typeof WIDTHS;
className?: string;
}
/** Conteneur de contenu standard : largeur max, marges et animation d'entrée. */
export function Page({ children, width = "default", className }: PageProps) {
return (
<div className={cn("fade-in mx-auto w-full px-4 py-5 sm:px-6 sm:py-8 lg:px-8", WIDTHS[width], className)}>
{children}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useState } from "react";
import { Link, useLocation } from "wouter";
import { Calendar, ChevronRight, Home, Images, LogOut, Menu, Plus } from "lucide-react";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { ThemeToggle } from "@/components/theme-provider";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { CREATE_ITEMS, isActivePath, visibleGroups } from "@/components/layout/nav-config";
import { useLogout, useSession } from "@/hooks/use-session";
import { cn } from "@/lib/utils";
const TABS = [
{ href: "/", label: "Accueil", icon: Home, testId: "tab-home" },
{ href: "/calendar", label: "Calendrier", icon: Calendar, testId: "tab-calendar" },
{ href: "/media", label: "Médias", icon: Images, testId: "tab-media" },
];
/** Barre de navigation inférieure (mobile et tablette, < lg). */
export function MobileNav() {
const [location, setLocation] = useLocation();
const [createOpen, setCreateOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const { session, isAdmin } = useSession();
const logout = useLogout();
const go = (href: string) => {
setCreateOpen(false);
setMenuOpen(false);
setLocation(href);
};
const tab = (t: (typeof TABS)[number]) => {
const active = isActivePath(location, t.href);
const Icon = t.icon;
return (
<Link
key={t.href}
href={t.href}
className={cn(
"flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium",
active ? "text-primary" : "text-muted-foreground",
)}
aria-current={active ? "page" : undefined}
data-testid={t.testId}
>
<Icon className="h-[22px] w-[22px]" strokeWidth={active ? 2.4 : 2} />
{t.label}
</Link>
);
};
// Les entrées déjà accessibles par les onglets ou « Créer » ne sont pas répétées dans le menu.
const menuGroups = visibleGroups(isAdmin)
.filter((g) => g.label !== "Créer")
.map((g) => ({ ...g, items: g.items.filter((i) => !["/", "/calendar", "/media"].includes(i.href)) }))
.filter((g) => g.items.length > 0);
return (
<>
<nav
className="fixed inset-x-0 bottom-0 z-40 border-t bg-card/95 backdrop-blur-lg pb-safe lg:hidden"
aria-label="Navigation mobile"
>
<div className="mx-auto flex h-16 max-w-xl items-stretch px-2">
{tab(TABS[0])}
{tab(TABS[1])}
<div className="flex flex-1 items-center justify-center">
<button
onClick={() => setCreateOpen(true)}
className="flex h-12 w-12 -translate-y-3 items-center justify-center rounded-2xl gradient-brand text-white shadow-lg shadow-primary/30 ring-4 ring-background"
aria-label="Créer"
data-testid="tab-create"
>
<Plus className="h-6 w-6" strokeWidth={2.5} />
</button>
</div>
{tab(TABS[2])}
<button
onClick={() => setMenuOpen(true)}
className={cn(
"flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium",
menuOpen ? "text-primary" : "text-muted-foreground",
)}
data-testid="tab-menu"
>
<Menu className="h-[22px] w-[22px]" />
Menu
</button>
</div>
</nav>
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
<SheetContent side="bottom" className="rounded-t-2xl pb-8">
<SheetHeader className="text-left">
<SheetTitle>Que voulez-vous créer ?</SheetTitle>
<SheetDescription>Choisissez le type de contenu à préparer.</SheetDescription>
</SheetHeader>
<div className="mt-4 grid grid-cols-2 gap-3">
{CREATE_ITEMS.map((item) => {
const Icon = item.icon;
return (
<button
key={item.href}
onClick={() => go(item.href)}
className="flex flex-col items-start gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent"
data-testid={`create-${item.href.replace(/\W+/g, "-")}`}
>
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="h-5 w-5" />
</span>
<span>
<span className="block text-sm font-semibold">{item.label}</span>
{item.description && <span className="block text-xs text-muted-foreground">{item.description}</span>}
</span>
</button>
);
})}
</div>
</SheetContent>
</Sheet>
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
<SheetContent side="bottom" className="max-h-[85vh] overflow-y-auto rounded-t-2xl pb-8">
<SheetHeader className="text-left">
<SheetTitle>Menu</SheetTitle>
<SheetDescription className="sr-only">Toutes les sections de Social Flow</SheetDescription>
</SheetHeader>
{session && (
<div className="mt-4 flex items-center gap-3 rounded-xl bg-muted/60 p-3">
<Avatar className="h-10 w-10">
<AvatarFallback className="bg-primary/10 font-semibold text-primary">
{session.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{session.username}</p>
<p className="text-xs text-muted-foreground">{isAdmin ? "Administrateur" : "Utilisateur"}</p>
</div>
</div>
)}
{menuGroups.map((group) => (
<div key={group.label ?? "root"} className="mt-5">
{group.label && (
<p className="mb-1.5 px-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group.label}</p>
)}
<div className="overflow-hidden rounded-xl border bg-card">
{group.items.map((item, i) => {
const Icon = item.icon;
return (
<button
key={item.href}
onClick={() => go(item.href)}
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-accent",
i > 0 && "border-t",
isActivePath(location, item.href) && "text-primary",
)}
data-testid={`menu-${item.testId}`}
>
<Icon className="h-5 w-5 text-muted-foreground" />
<span className="flex-1 font-medium">{item.label}</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</button>
);
})}
</div>
</div>
))}
<div className="mt-5">
<p className="mb-1.5 px-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Apparence</p>
<ThemeToggle />
</div>
<button
onClick={() => logout.mutate()}
disabled={logout.isPending}
className="mt-5 flex w-full items-center justify-center gap-2 rounded-xl border px-4 py-3 text-sm font-medium text-destructive hover:bg-destructive/5"
data-testid="button-logout-mobile"
>
<LogOut className="h-4 w-4" />
{logout.isPending ? "Déconnexion…" : "Se déconnecter"}
</button>
</SheetContent>
</Sheet>
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
import {
BarChart3,
Bot,
Calendar,
CalendarClock,
Clock,
Database,
Film,
Home,
Images,
Link2,
Music,
PenSquare,
Settings,
UserCog,
Wand2,
type LucideIcon,
} from "lucide-react";
export interface NavItem {
href: string;
label: string;
icon: LucideIcon;
/** Courte description affichée dans les menus mobiles. */
description?: string;
adminOnly?: boolean;
testId: string;
}
export interface NavGroup {
label: string | null;
items: NavItem[];
adminOnly?: boolean;
}
/** Source unique de la navigation (sidebar desktop, barre mobile, menus). */
export const NAV_GROUPS: NavGroup[] = [
{
label: null,
items: [{ href: "/", label: "Tableau de bord", icon: Home, testId: "link-tableau-de-bord" }],
},
{
label: "Créer",
items: [
{ href: "/new", label: "Publication", icon: PenSquare, description: "Photo ou vidéo avec texte", testId: "link-nouvelle-publication" },
{ href: "/reel", label: "Reel", icon: Film, description: "Montage guidé avec musique et voix", testId: "link-nouveau-reel" },
{ href: "/reel/schedule", label: "Programmer un Reel", icon: CalendarClock, description: "Vidéo déjà montée", testId: "link-programmer-un-reel" },
{ href: "/image-editor", label: "Éditeur d'images", icon: Wand2, description: "Bandeaux, prix et logo", testId: "link-éditeur-d'images" },
],
},
{
label: "Planifier",
items: [
{ href: "/calendar", label: "Calendrier", icon: Calendar, testId: "link-calendrier" },
{ href: "/history", label: "Historique", icon: Clock, testId: "link-historique" },
],
},
{
label: "Bibliothèque",
items: [
{ href: "/media", label: "Médiathèque", icon: Images, testId: "link-médiathèque" },
{ href: "/audio-admin", label: "Musiques", icon: Music, adminOnly: true, testId: "link-audio-admin" },
],
},
{
label: "Analyser",
items: [
{ href: "/analytics", label: "Statistiques", icon: BarChart3, testId: "link-analytics" },
{ href: "/ai", label: "Assistant IA", icon: Bot, adminOnly: true, testId: "link-assistant-ia" },
],
},
{
label: "Administration",
adminOnly: true,
items: [
{ href: "/pages", label: "Comptes connectés", icon: Link2, testId: "link-pages-gérées" },
{ href: "/users", label: "Utilisateurs", icon: UserCog, testId: "link-users" },
{ href: "/settings", label: "Paramètres", icon: Settings, testId: "link-settings" },
{ href: "/sql", label: "Base de données", icon: Database, testId: "link-sql" },
],
},
];
export const CREATE_ITEMS = NAV_GROUPS.find((g) => g.label === "Créer")!.items;
export function visibleGroups(isAdmin: boolean): NavGroup[] {
return NAV_GROUPS.filter((g) => isAdmin || !g.adminOnly)
.map((g) => ({ ...g, items: g.items.filter((i) => isAdmin || !i.adminOnly) }))
.filter((g) => g.items.length > 0);
}
export function isActivePath(location: string, href: string): boolean {
if (href === "/") return location === "/";
// /reel ne doit pas rester actif sur /reel/schedule
if (href === "/reel") return location === "/reel";
return location === href || location.startsWith(href + "/");
}
/** Titre de la page courante (header mobile). */
export function titleForPath(location: string): string {
for (const g of NAV_GROUPS) {
for (const i of g.items) {
if (isActivePath(location, i.href)) return i.label;
}
}
return "Social Flow";
}
@@ -0,0 +1,52 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface PageHeaderProps {
title: ReactNode;
description?: ReactNode;
icon?: LucideIcon;
/** Boutons d'action affichés à droite (empilés sous le titre sur mobile). */
actions?: ReactNode;
className?: string;
}
/** En-tête standard de toutes les pages : icône, titre, description et actions. */
export function PageHeader({ title, description, icon: Icon, actions, className }: PageHeaderProps) {
return (
<header className={cn("mb-6 flex flex-col gap-4 sm:mb-8 sm:flex-row sm:items-start sm:justify-between", className)}>
<div className="flex min-w-0 items-start gap-3 sm:gap-4">
{Icon && (
<div className="hidden h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:flex">
<Icon className="h-5 w-5" />
</div>
)}
<div className="min-w-0">
<h1 className="text-2xl font-semibold tracking-tight text-foreground sm:text-[28px] sm:leading-9">{title}</h1>
{description && <p className="mt-1 text-sm text-muted-foreground sm:text-[15px]">{description}</p>}
</div>
</div>
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
</header>
);
}
interface SectionHeaderProps {
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
className?: string;
}
/** Titre de section à l'intérieur d'une page. */
export function SectionHeader({ title, description, actions, className }: SectionHeaderProps) {
return (
<div className={cn("mb-3 flex items-end justify-between gap-3", className)}>
<div className="min-w-0">
<h2 className="text-base font-semibold text-foreground">{title}</h2>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
{actions}
</div>
);
}
+71 -82
View File
@@ -1,94 +1,83 @@
import { useQuery } from "@tanstack/react-query";
import { Users } from "lucide-react";
import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
import { Link } from "wouter";
import { ArrowRight, Link2, Users } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
import { PlatformIcon, platformLabel } from "@/components/platform-icon";
interface ManagedPage {
id: string;
pageName: string;
platform: string;
isActive: string;
followersCount?: number | null;
}
export default function ManagedPages() {
const { data: pages, isLoading } = useQuery({
const { data: pages, isLoading } = useQuery<ManagedPage[]>({
queryKey: ["/api/pages"],
});
if (isLoading) {
return (
<div className="bg-card rounded-2xl border border-border/50 p-8 shadow-lg">
<div className="skeleton h-8 w-48 mb-6 rounded-lg" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="skeleton h-40 rounded-xl" />
))}
</div>
</div>
);
}
return (
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg">
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg">
<Users className="text-white w-6 h-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">Pages gérées</h3>
<p className="text-sm text-muted-foreground">
{(pages as any[])?.length || 0} page(s) connectée(s)
</p>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle>Comptes connectés</CardTitle>
<CardDescription>{pages?.length ?? 0} compte(s) prêt(s) à publier</CardDescription>
</div>
</div>
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{pages && (pages as any[]).length > 0 ? (
(pages as any[]).map((page: any) => (
<div
key={page.id}
className="border border-border/50 rounded-2xl p-6 hover:shadow-xl hover:border-primary/30 transition-all cursor-pointer bg-card"
data-testid={`page-card-${page.id}`}
>
<div className="flex items-start justify-between mb-5">
<div className={`
w-14 h-14 rounded-xl flex items-center justify-center shadow-md
${page.platform === "facebook" ? "bg-[#1877F2]/10" : page.platform === "tiktok" ? "bg-foreground/10" : "bg-[#E4405F]/10"}
`}>
{page.platform === "facebook" ? (
<SiFacebook className="text-[#1877F2] text-2xl" />
) : page.platform === "tiktok" ? (
<SiTiktok className="text-foreground text-2xl" />
) : (
<SiInstagram className="text-[#E4405F] text-2xl" />
)}
</div>
<span className={`
inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold
${page.isActive === "true" ? "bg-success/20 text-success" : "bg-muted text-muted-foreground"}
`}>
{page.isActive === "true" ? "Actif" : "Inactif"}
</span>
</div>
<h4 className="font-semibold text-foreground text-lg mb-2">{page.pageName}</h4>
<p className="text-sm text-muted-foreground mb-4 font-medium">
{page.platform === "facebook" ? "Facebook Page" : page.platform === "tiktok" ? "Compte TikTok" : "Instagram Business"}
</p>
<div className="flex items-center justify-between pt-4 border-t border-border/50">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground font-medium">
{page.followersCount?.toLocaleString() || 0} abonnés
</span>
</div>
<Button asChild variant="ghost" size="sm" className="text-primary">
<Link href="/pages">
Gérer <ArrowRight className="h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-1">
{[1, 2, 3].map((i) => (
<div key={i} className="skeleton h-[72px] rounded-lg" />
))}
</div>
) : pages && pages.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-1">
{pages.map((page) => (
<div key={page.id} className="flex items-center gap-3 rounded-lg border p-3" data-testid={`page-card-${page.id}`}>
<PlatformIcon platform={page.platform} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{page.pageName}</p>
<p className="flex items-center gap-1 text-xs text-muted-foreground">
{platformLabel(page.platform)}
{page.followersCount ? (
<>
<span>·</span>
<Users className="h-3 w-3" />
{page.followersCount.toLocaleString("fr-FR")}
</>
) : null}
</p>
</div>
<Badge variant={page.isActive === "true" ? "success" : "muted"}>
{page.isActive === "true" ? "Actif" : "Inactif"}
</Badge>
</div>
))
) : (
<div className="col-span-full text-center py-16">
<div className="w-20 h-20 rounded-2xl bg-muted/30 flex items-center justify-center mx-auto mb-6">
<Users className="w-10 h-10 text-muted-foreground opacity-50" />
</div>
<p className="text-muted-foreground text-lg">Aucune page connectée</p>
</div>
)}
</div>
</div>
</div>
))}
</div>
) : (
<EmptyState
compact
icon={Link2}
title="Aucun compte connecté"
description="Connectez une page Facebook, Instagram ou TikTok pour commencer à publier."
action={
<Button asChild size="sm">
<Link href="/pages">Connecter un compte</Link>
</Button>
}
/>
)}
</CardContent>
</Card>
);
}
+11 -20
View File
@@ -23,12 +23,7 @@ function getStageLabel(post: OngoingReel): string {
return (post.generationStep && STEP_LABELS[post.generationStep]) || "Démarrage du traitement…";
}
interface OngoingReelsProps {
/** Compact layout for mobile */
compact?: boolean;
}
export default function OngoingReels({ compact = false }: OngoingReelsProps) {
export default function OngoingReels() {
const { data: ongoingPosts = [] } = useQuery<OngoingReel[]>({
queryKey: ["/api/reels/ongoing"],
// Cette route ne renvoie que les générations en cours : tant qu'elle est
@@ -65,13 +60,9 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
}
return (
<Card
className={`rounded-2xl border-border/50 shadow-lg ${compact ? "" : ""
}`}
data-testid="card-ongoing-reels"
>
<CardHeader className={compact ? "pb-2 px-4 pt-4" : ""}>
<CardTitle className={`flex items-center gap-2 ${compact ? "text-base" : ""}`}>
<Card data-testid="card-ongoing-reels">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
{ongoingPosts.some((p) => p.generationStatus !== "failed") ? (
<Loader2 className="w-5 h-5 animate-spin text-primary" />
) : (
@@ -80,8 +71,8 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
Reels en cours
</CardTitle>
</CardHeader>
<CardContent className={compact ? "px-4 pb-4 pt-0" : ""}>
<div className={`space-y-${compact ? "3" : "4"}`}>
<CardContent>
<div className="space-y-3">
{ongoingPosts.map((post) => {
const progress = post.generationProgress ?? 0;
const isFailed = post.generationStatus === "failed";
@@ -89,17 +80,17 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
return (
<div
key={post.id}
className={`flex items-start gap-3 p-${compact ? "3" : "4"} rounded-lg border border-border/50 bg-muted/30`}
className="flex items-start gap-3 rounded-lg border bg-muted/30 p-3"
data-testid={`ongoing-reel-${post.id}`}
>
{/* Icon */}
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center flex-shrink-0">
<Clapperboard className="w-4 h-4 text-white" />
<div className="w-9 h-9 rounded-lg bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
<Clapperboard className="w-4 h-4" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className={`font-medium truncate ${compact ? "text-sm" : "text-sm"}`}>
<p className="font-medium truncate text-sm">
{post.content || "Reel sans texte"}
</p>
@@ -130,7 +121,7 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
)}
{progress >= 100 && !isFailed && (
<div className="flex items-center gap-1.5 mt-2 text-green-500">
<div className="flex items-center gap-1.5 mt-2 text-success">
<CheckCircle2 className="w-4 h-4" />
<span className="text-xs font-medium">Terminé</span>
</div>
+35
View File
@@ -0,0 +1,35 @@
import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
import { cn } from "@/lib/utils";
export type Platform = "facebook" | "instagram" | "tiktok" | string;
const META: Record<string, { label: string; icon: typeof SiFacebook; text: string; bg: string }> = {
facebook: { label: "Facebook", icon: SiFacebook, text: "text-facebook", bg: "bg-facebook/10" },
instagram: { label: "Instagram", icon: SiInstagram, text: "text-instagram", bg: "bg-instagram/10" },
tiktok: { label: "TikTok", icon: SiTiktok, text: "text-tiktok", bg: "bg-tiktok/10" },
};
export function platformLabel(platform: Platform): string {
return META[platform]?.label ?? platform;
}
/** Icône de plateforme sur pastille teintée (couleurs officielles via tokens). */
export function PlatformIcon({
platform,
size = "md",
className,
}: {
platform: Platform;
size?: "sm" | "md" | "lg";
className?: string;
}) {
const meta = META[platform] ?? META.facebook;
const Icon = meta.icon;
const box = { sm: "h-7 w-7 rounded-md", md: "h-10 w-10 rounded-lg", lg: "h-12 w-12 rounded-xl" }[size];
const icon = { sm: "h-3.5 w-3.5", md: "h-5 w-5", lg: "h-6 w-6" }[size];
return (
<span className={cn("inline-flex shrink-0 items-center justify-center", box, meta.bg, meta.text, className)}>
<Icon className={icon} />
</span>
);
}
-124
View File
@@ -1,124 +0,0 @@
import { Button } from "@/components/ui/button";
import { Image, Video, CalendarPlus, Clock, Plug, Zap, PlusCircle } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
export default function QuickActions() {
const { data: recentActivity } = useQuery({
queryKey: ["/api/posts"],
select: (data) => (data as any[])?.slice(0, 4) || [],
});
return (
<div className="space-y-6">
<div className="bg-card rounded-2xl border border-border/50 p-6 shadow-lg">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center">
<PlusCircle className="text-white w-5 h-5" />
</div>
<h3 className="text-lg font-semibold text-foreground">Nouvelle publication</h3>
</div>
<div className="space-y-3">
<Button className="w-full bg-gradient-to-r from-primary to-secondary hover:opacity-90 rounded-xl" data-testid="button-new-media-post">
<Image className="w-4 h-4 mr-2" />
Publication avec média
</Button>
<Button variant="outline" className="w-full rounded-xl border-border/50" data-testid="button-new-story">
<Video className="w-4 h-4 mr-2" />
Story vidéo
</Button>
<Button variant="outline" className="w-full rounded-xl border-border/50" data-testid="button-schedule-series">
<CalendarPlus className="w-4 h-4 mr-2" />
Planifier série
</Button>
</div>
</div>
<div className="bg-card rounded-2xl border border-border/50 p-6 shadow-lg">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-warning/10 flex items-center justify-center">
<Clock className="text-warning w-5 h-5" />
</div>
<h3 className="text-lg font-semibold text-foreground">Activité récente</h3>
</div>
<div className="space-y-4">
{recentActivity && recentActivity.length > 0 ? (
recentActivity.map((activity: any, index: number) => (
<div
key={activity.id}
className="flex items-start gap-4 pb-4 border-b border-border/50 last:border-0 last:pb-0"
data-testid={`activity-${index}`}
>
<div className="w-2 h-2 rounded-full bg-success mt-2 shadow-sm"></div>
<div className="flex-1">
<p className="text-sm font-medium text-foreground">
{activity.status === "scheduled" ? "Publication planifiée" : "Brouillon créé"}
</p>
<p className="text-xs text-muted-foreground mt-1 font-medium">
{new Date(activity.createdAt).toLocaleDateString("fr-FR", {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
))
) : (
<div className="text-center py-8">
<Clock className="w-12 h-12 text-muted-foreground opacity-30 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">Aucune activité récente</p>
</div>
)}
</div>
</div>
<div className="bg-card rounded-2xl border border-border/50 p-6 shadow-lg">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-success/10 flex items-center justify-center">
<Zap className="text-success w-5 h-5" />
</div>
<h3 className="text-lg font-semibold text-foreground">État des services</h3>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between" data-testid="status-openrouter">
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-success shadow-sm"></div>
<span className="text-sm font-medium text-foreground">OpenRouter API</span>
</div>
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-success/20 text-success">
Actif
</span>
</div>
<div className="flex items-center justify-between" data-testid="status-facebook">
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-success shadow-sm"></div>
<span className="text-sm font-medium text-foreground">Facebook API</span>
</div>
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-success/20 text-success">
Actif
</span>
</div>
<div className="flex items-center justify-between" data-testid="status-instagram">
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-success shadow-sm"></div>
<span className="text-sm font-medium text-foreground">Instagram API</span>
</div>
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-success/20 text-success">
Actif
</span>
</div>
<div className="flex items-center justify-between" data-testid="status-database">
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-success shadow-sm"></div>
<span className="text-sm font-medium text-foreground">PostgreSQL</span>
</div>
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-success/20 text-success">
Actif
</span>
</div>
</div>
</div>
</div>
);
}
+89 -95
View File
@@ -1,7 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Facebook, Instagram, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react";
import { SiTiktok } from "react-icons/si";
import { ArrowRight, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react";
import { Link } from "wouter";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/empty-state";
import { PlatformIcon } from "@/components/platform-icon";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema";
@@ -148,115 +151,106 @@ export default function RecentPublications() {
};
return (
<Card className="rounded-2xl border-border/50 shadow-lg" data-testid="card-recent-publications">
<CardHeader>
<CardTitle>Historique des publications</CardTitle>
<CardDescription>
Vos 10 dernières publications sur les réseaux sociaux
</CardDescription>
<Card data-testid="card-recent-publications">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle>Dernières publications</CardTitle>
<CardDescription>Les 10 publications les plus récentes</CardDescription>
</div>
<Button asChild variant="ghost" size="sm" className="text-primary">
<Link href="/history">
Tout voir <ArrowRight className="h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 bg-muted rounded-lg animate-pulse" />
<div key={i} className="skeleton h-16 rounded-lg" />
))}
</div>
) : recentPublished.length === 0 ? (
<div className="text-center py-12">
<Clock className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">Aucune publication pour le moment</p>
</div>
<EmptyState
compact
icon={Clock}
title="Aucune publication pour le moment"
description="Vos publications apparaîtront ici dès qu'elles seront envoyées."
action={
<Button asChild size="sm">
<Link href="/new">Créer une publication</Link>
</Button>
}
/>
) : (
<div className="space-y-3">
<ul className="divide-y">
{recentPublished.map((scheduledPost) => (
<div
<li
key={scheduledPost.id}
className="flex items-start gap-4 p-4 rounded-lg border border-border/50 hover:bg-muted/50 transition-colors"
className="group flex items-center gap-3 py-3 first:pt-0 last:pb-0"
data-testid={`publication-${scheduledPost.id}`}
>
{/* Platform icon */}
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${scheduledPost.page?.platform === 'facebook'
? 'bg-blue-500'
: scheduledPost.page?.platform === 'tiktok'
? 'bg-black'
: 'bg-gradient-to-br from-purple-500 to-pink-500'
}`}>
{scheduledPost.page?.platform === 'facebook' ? (
<Facebook className="w-5 h-5 text-white" />
) : scheduledPost.page?.platform === 'tiktok' ? (
<SiTiktok className="w-5 h-5 text-white" />
) : (
<Instagram className="w-5 h-5 text-white" />
)}
</div>
<PlatformIcon platform={scheduledPost.page?.platform ?? "facebook"} />
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm truncate" data-testid={`text-page-name-${scheduledPost.id}`}>
{scheduledPost.page?.pageName || 'Page inconnue'}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{scheduledPost.post?.content ? (
<span className="line-clamp-2">{scheduledPost.post.content}</span>
) : (
<span className="italic">Aucun texte</span>
)}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handlePreviewPost(scheduledPost)}
className="h-8 w-8 p-0"
data-testid={`button-preview-post-${scheduledPost.id}`}
title="Prévisualiser"
>
<Eye className="w-4 h-4 text-muted-foreground hover:text-foreground" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick(scheduledPost.postId)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive transition-colors"
data-testid={`button-delete-post-${scheduledPost.id}`}
title="Supprimer"
>
<Trash2 className="w-4 h-4" />
</Button>
{scheduledPost.error ? (
<XCircle className="w-4 h-4 text-destructive" />
) : (
<CheckCircle2 className="w-4 h-4 text-green-500" />
)}
</div>
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1 capitalize">
{getPostTypeIcon(scheduledPost.postType)}
{scheduledPost.postType === 'feed' ? 'Feed' :
scheduledPost.postType === 'story' ? 'Story' :
scheduledPost.postType === 'reel' ? 'Reel' : 'Feed & Story'}
</span>
<span>•</span>
<span>
{format(new Date(scheduledPost.scheduledAt!), "d MMM yyyy 'à' HH:mm", { locale: fr })}
</span>
{scheduledPost.error && (
<>
<span>•</span>
<span className="text-destructive">Erreur</span>
</>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium" data-testid={`text-page-name-${scheduledPost.id}`}>
{scheduledPost.page?.pageName || "Page inconnue"}
</p>
{scheduledPost.error ? (
<Badge variant="danger" className="gap-1">
<XCircle className="h-3 w-3" /> Échec
</Badge>
) : (
<Badge variant="success" className="gap-1">
<CheckCircle2 className="h-3 w-3" /> Publié
</Badge>
)}
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{scheduledPost.post?.content || <span className="italic">Aucun texte</span>}
</p>
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
{getPostTypeIcon(scheduledPost.postType)}
{scheduledPost.postType === "feed"
? "Feed"
: scheduledPost.postType === "story"
? "Story"
: scheduledPost.postType === "reel"
? "Reel"
: "Feed & Story"}
<span>·</span>
{format(new Date(scheduledPost.scheduledAt!), "d MMM yyyy 'à' HH:mm", { locale: fr })}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handlePreviewPost(scheduledPost)}
className="h-8 w-8 text-muted-foreground"
data-testid={`button-preview-post-${scheduledPost.id}`}
title="Prévisualiser"
aria-label="Prévisualiser"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteClick(scheduledPost.postId)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
data-testid={`button-delete-post-${scheduledPost.id}`}
title="Supprimer"
aria-label="Supprimer"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</li>
))}
</div>
</ul>
)}
</CardContent>
@@ -274,7 +268,7 @@ export default function RecentPublications() {
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
<AlertDialogTitle>Supprimer cette publication ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action est irréversible. Cela supprimera définitivement cette publication de votre historique et des statistiques associées.
</AlertDialogDescription>
@@ -1,49 +0,0 @@
import { ComponentType, Suspense, lazy } from 'react';
import { useIsMobile } from '@/hooks/useIsMobile';
interface ResponsiveRouteProps {
desktop: ComponentType<any>;
mobile: ComponentType<any>;
fallback?: React.ReactNode;
}
/**
* ResponsiveRoute component that automatically switches between desktop and mobile versions
* Uses lazy loading for optimal performance
*/
export function ResponsiveRoute({ desktop: Desktop, mobile: Mobile, fallback }: ResponsiveRouteProps) {
const isMobile = useIsMobile();
// Default loading fallback
const defaultFallback = (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
return (
<Suspense fallback={fallback || defaultFallback}>
{isMobile ? <Mobile /> : <Desktop />}
</Suspense>
);
}
/**
* Factory function to create lazy-loaded responsive routes
* Usage:
* const Dashboard = createResponsiveRoute(
* () => import('./pages/dashboard'),
* () => import('./pages/mobile/dashboard')
* );
*/
export function createResponsiveRoute(
desktopImport: () => Promise<{ default: ComponentType<any> }>,
mobileImport: () => Promise<{ default: ComponentType<any> }>
) {
const DesktopLazy = lazy(desktopImport);
const MobileLazy = lazy(mobileImport);
return function ResponsiveRouteWrapper() {
return <ResponsiveRoute desktop={DesktopLazy} mobile={MobileLazy} />;
};
}
+151 -278
View File
@@ -1,305 +1,178 @@
import { Home, PlusCircle, CalendarClock, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useLocation } from "wouter";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, AUTH_MUTATION, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { ChevronsLeft, ChevronsRight, LogOut, Plus } from "lucide-react";
import { Logo, LogoMark } from "@/components/brand/logo";
import { ThemeToggle } from "@/components/theme-provider";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isActivePath, visibleGroups, type NavItem } from "@/components/layout/nav-config";
import { useLogout, useSession } from "@/hooks/use-session";
import { cn } from "@/lib/utils";
interface SidebarProps {
onLinkClick?: () => void;
function readCollapsed(): boolean {
try {
return localStorage.getItem("sidebar-collapsed") === "true";
} catch {
return false;
}
}
export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
function NavLink({ item, active, collapsed }: { item: NavItem; active: boolean; collapsed: boolean }) {
const Icon = item.icon;
const link = (
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={cn(
"group relative flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-sidebar-accent hover:text-foreground",
collapsed && "justify-center px-0",
)}
data-testid={item.testId}
>
{active && <span className="absolute -left-3 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />}
<Icon className="h-[18px] w-[18px] shrink-0" />
{!collapsed && <span className="truncate">{item.label}</span>}
</Link>
);
if (!collapsed) return link;
return (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
);
}
/** Barre latérale desktop (≥ lg). Sur mobile, la navigation passe par MobileNav. */
export default function Sidebar() {
const [location] = useLocation();
const [, setLocation] = useLocation();
const { toast } = useToast();
const [isCollapsed, setIsCollapsed] = useState(() => {
const saved = localStorage.getItem('sidebar-collapsed');
return saved === 'true';
});
const [adminExpanded, setAdminExpanded] = useState(() => {
const saved = localStorage.getItem('sidebar-admin-expanded');
return saved !== 'false'; // default: expanded
});
const { session, isAdmin } = useSession();
const logout = useLogout();
const [collapsed, setCollapsed] = useState(readCollapsed);
useEffect(() => {
localStorage.setItem('sidebar-collapsed', String(isCollapsed));
}, [isCollapsed]);
useEffect(() => {
localStorage.setItem('sidebar-admin-expanded', String(adminExpanded));
}, [adminExpanded]);
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
queryKey: ["/api/auth/session"],
retry: false,
});
const isAdmin = session?.role === "admin";
const handleLinkClick = (href: string, e: React.MouseEvent) => {
e.preventDefault();
// Always close sidebar on mobile first
if (onLinkClick) {
onLinkClick();
try {
localStorage.setItem("sidebar-collapsed", String(collapsed));
} catch {
/* stockage indisponible : on ignore */
}
// Navigate after a small delay to ensure state update completes
setTimeout(() => {
setLocation(href);
}, 10);
};
}, [collapsed]);
const logoutMutation = useMutation({
mutationKey: [AUTH_MUTATION, "logout"],
mutationFn: async () => {
const res = await apiRequest("POST", "/api/auth/logout", {});
return await res.json();
},
onSuccess: () => {
queryClient.clear();
setLocation("/login");
toast({
title: "Déconnecté",
description: "Vous avez été déconnecté avec succès",
});
},
});
const navItems = [
{ icon: Home, label: "Tableau de bord", href: "/", badge: null },
{ icon: PlusCircle, label: "Nouvelle publication", href: "/new", badge: null },
{ icon: Video, label: "Nouveau Reel", href: "/reel", badge: null },
{ icon: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule", badge: null },
{ icon: Calendar, label: "Calendrier", href: "/calendar", badge: null },
{ icon: Images, label: "Médiathèque", href: "/media", badge: null },
{ icon: Wand2, label: "Éditeur d'images", href: "/image-editor", badge: null },
{ icon: BarChart3, label: "Analytics", href: "/analytics", badge: null },
];
const statsItems = [
{ icon: Clock, label: "Historique", href: "/history" },
];
const groups = visibleGroups(isAdmin);
const initials = session?.username.substring(0, 2).toUpperCase() ?? "?";
return (
<aside className={`bg-sidebar border-r border-sidebar-border flex flex-col h-screen transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-72'}`}>
{/* Logo & Brand */}
<div className="p-6 border-b border-sidebar-border">
<div className="flex items-center gap-3">
<div className="w-12 h-12 gradient-primary rounded-xl flex items-center justify-center shadow-lg">
<svg className="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</div>
{!isCollapsed && (
<div>
<h1 className="text-xl font-bold gradient-text">Social Flow</h1>
<p className="text-xs text-muted-foreground">Automatisation sociale</p>
</div>
)}
</div>
<aside
className={cn(
"sticky top-0 hidden h-screen shrink-0 flex-col border-r border-sidebar-border bg-sidebar transition-[width] duration-200 lg:flex",
collapsed ? "w-[76px]" : "w-64",
)}
>
<div className={cn("flex h-16 items-center border-b border-sidebar-border", collapsed ? "justify-center" : "px-5")}>
<Link href="/" aria-label="Accueil Social Flow">
{collapsed ? <LogoMark size={34} /> : <Logo size={34} />}
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 overflow-y-auto">
<ul className="space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = location === item.href;
<div className={cn("pt-4", collapsed ? "px-3" : "px-4")}>
<Button asChild variant="brand" className={cn("w-full", collapsed && "px-0")} data-testid="button-sidebar-create">
<Link href="/new" aria-label="Créer une publication">
<Plus className="h-4 w-4" />
{!collapsed && "Créer"}
</Link>
</Button>
</div>
return (
<li key={item.href} className="relative group">
<a
href={item.href}
onClick={(e) => handleLinkClick(item.href, e)}
className={`
flex items-center gap-3 px-4 py-3 rounded-xl font-medium transition-all relative cursor-pointer
${isActive
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
}
${isCollapsed ? 'justify-center' : ''}
`}
data-testid={`link-${item.label.toLowerCase().replace(/\s+/g, '-')}`}
>
{isActive && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 gradient-primary rounded-r-full" />
)}
<Icon className={`w-5 h-5 ${isActive ? 'text-primary' : ''}`} />
{!isCollapsed && (
<>
<span className="flex-1">{item.label}</span>
{item.badge && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold gradient-primary text-white shadow-sm">
{item.badge}
</span>
)}
</>
)}
</a>
{isCollapsed && (
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
{item.label}
</div>
)}
</li>
);
})}
</ul>
{/* Stats Section */}
<div className="mt-8 pt-6 border-t border-sidebar-border">
{!isCollapsed && (
<p className="text-xs font-semibold text-muted-foreground mb-3 px-4 uppercase tracking-wider">Statistiques</p>
)}
<ul className="space-y-1">
{statsItems.map((item) => {
const Icon = item.icon;
const isActive = location === item.href;
return (
<li key={item.href} className="relative group">
<a
href={item.href}
onClick={(e) => handleLinkClick(item.href, e)}
className={`
flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative cursor-pointer
${isActive
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
}
${isCollapsed ? 'justify-center' : ''}
`}
data-testid={`link-${item.label.toLowerCase()}`}
>
{isActive && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 gradient-primary rounded-r-full" />
)}
<Icon className={`w-5 h-5 ${isActive ? 'text-primary' : ''}`} />
{!isCollapsed && <span>{item.label}</span>}
</a>
{isCollapsed && (
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
{item.label}
</div>
)}
<nav className={cn("flex-1 overflow-y-auto py-4 scrollbar-none", collapsed ? "px-3" : "px-4")} aria-label="Navigation principale">
{groups.map((group, gi) => (
<div key={group.label ?? gi} className={cn(gi > 0 && "mt-5")}>
{group.label &&
(collapsed ? (
<div className="mx-auto mb-2 h-px w-6 bg-sidebar-border" />
) : (
<p className="mb-1.5 px-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/80">
{group.label}
</p>
))}
<ul className="space-y-0.5">
{group.items.map((item) => (
<li key={item.href}>
<NavLink item={item} active={isActivePath(location, item.href)} collapsed={collapsed} />
</li>
);
})}
</ul>
</div>
))}
</ul>
</div>
))}
</nav>
{/* Admin & User Section */}
<div className="p-4 border-t border-sidebar-border space-y-2">
{isAdmin && (
<>
{/* Admin section toggle */}
<button
onClick={() => !isCollapsed && setAdminExpanded(v => !v)}
className={`w-full flex items-center gap-3 px-4 py-2 rounded-xl transition-all text-muted-foreground hover:text-foreground hover:bg-sidebar-accent relative group ${isCollapsed ? 'justify-center' : ''}`}
>
<Shield className="w-4 h-4 shrink-0" />
{!isCollapsed && (
<>
<span className="text-xs font-semibold uppercase tracking-wider flex-1 text-left">Administration</span>
<ChevronDown className={`w-3.5 h-3.5 transition-transform duration-200 ${adminExpanded ? '' : '-rotate-90'}`} />
</>
)}
{isCollapsed && (
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
Administration
</div>
)}
</button>
{/* Admin links — collapsible */}
{(adminExpanded || isCollapsed) && (
<div className={`space-y-0.5 ${!isCollapsed ? 'pl-2' : ''}`}>
{[
{ href: "/pages", icon: Users, label: "Pages gérées", testId: "link-pages-gérées" },
{ href: "/ai", icon: Bot, label: "Assistant IA", testId: "link-assistant-ia" },
{ href: "/users", icon: UserCog, label: "Utilisateurs", testId: "link-users" },
{ href: "/sql", icon: Database,label: "SQL", testId: "link-sql" },
{ href: "/audio-admin", icon: Music, label: "Bibliothèque Audio", testId: "link-audio-admin" },
{ href: "/settings", icon: Settings,label: "Paramètres", testId: "link-settings" },
].map(({ href, icon: Icon, label, testId }) => (
<a
key={href}
href={href}
onClick={(e) => handleLinkClick(href, e)}
className={`
flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all relative group cursor-pointer
${location === href
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
}
${isCollapsed ? 'justify-center' : ''}
`}
data-testid={testId}
>
{location === href && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 gradient-primary rounded-r-full" />
)}
<Icon className="w-4 h-4 shrink-0" />
{!isCollapsed && <span className="text-sm">{label}</span>}
{isCollapsed && (
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
{label}
</div>
)}
</a>
))}
</div>
)}
</>
)}
<div className="pt-2 relative group">
<Button
variant="ghost"
className={`w-full text-muted-foreground hover:text-foreground hover:bg-sidebar-accent rounded-xl ${isCollapsed ? 'justify-center px-0' : 'justify-start'}`}
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
data-testid="button-logout"
>
<LogOut className={`w-5 h-5 ${isCollapsed ? '' : 'mr-3'}`} />
{!isCollapsed && (logoutMutation.isPending ? "Déconnexion..." : "Déconnexion")}
</Button>
{isCollapsed && (
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 top-0">
Déconnexion
</div>
)}
</div>
{/* User Profile Card */}
<div className={cn("border-t border-sidebar-border p-3", collapsed && "flex flex-col items-center gap-2")}>
{session && (
<div className={`mt-4 bg-gradient-to-br from-primary/5 to-secondary/5 border border-primary/10 rounded-xl ${isCollapsed ? 'p-2' : 'p-4'}`}>
<div className={`flex items-center gap-3 ${isCollapsed ? 'justify-center' : ''}`}>
<Avatar className="w-10 h-10 border-2 border-primary/20">
<AvatarFallback className="gradient-primary text-white font-semibold">
{session.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{!isCollapsed && (
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{session.username}</p>
<p className="text-xs text-muted-foreground capitalize">{session.role}</p>
</div>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
"flex w-full items-center gap-3 rounded-lg p-2 text-left transition-colors hover:bg-sidebar-accent",
collapsed && "justify-center",
)}
data-testid="button-user-menu"
>
<Avatar className="h-9 w-9">
<AvatarFallback className="bg-primary/10 text-sm font-semibold text-primary">{initials}</AvatarFallback>
</Avatar>
{!collapsed && (
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{session.username}</p>
<p className="text-xs text-muted-foreground">{isAdmin ? "Administrateur" : "Utilisateur"}</p>
</div>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side={collapsed ? "right" : "top"} align="start" className="w-60">
<DropdownMenuLabel className="font-normal">
<p className="text-sm font-medium">{session.username}</p>
<p className="text-xs text-muted-foreground">{isAdmin ? "Administrateur" : "Utilisateur"}</p>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-2 py-1.5">
<p className="mb-1.5 text-xs text-muted-foreground">Apparence</p>
<ThemeToggle />
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => logout.mutate()}
disabled={logout.isPending}
className="text-destructive focus:text-destructive"
data-testid="button-logout"
>
<LogOut className="mr-2 h-4 w-4" />
{logout.isPending ? "Déconnexion…" : "Se déconnecter"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Toggle Button (Desktop only) */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="mt-2 w-full hidden lg:flex items-center justify-center p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 border-2 border-border hover:border-primary/50 rounded-xl transition-all"
onClick={() => setCollapsed((c) => !c)}
className={cn(
"mt-1 flex w-full items-center justify-center gap-2 rounded-lg p-2 text-xs text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground",
)}
aria-label={collapsed ? "Déplier le menu" : "Replier le menu"}
data-testid="button-toggle-sidebar"
>
{isCollapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronLeft className="w-5 h-5" />}
{collapsed ? <ChevronsRight className="h-4 w-4" /> : <><ChevronsLeft className="h-4 w-4" /> Replier</>}
</button>
</div>
</aside>
+61 -56
View File
@@ -1,5 +1,7 @@
import { CalendarCheck, Users, Bot, Images, TrendingUp, TrendingDown } from "lucide-react";
import { CalendarCheck, Link2, Sparkles, Images, TrendingUp, TrendingDown } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "wouter";
import { cn } from "@/lib/utils";
interface StatsResponse {
scheduledPosts: number;
@@ -20,46 +22,49 @@ export default function StatsCards() {
const statCards = [
{
icon: CalendarCheck,
iconColor: "from-blue-500 to-blue-600",
title: stats?.scheduledPosts ?? 0,
subtitle: "Publications planifiées",
tone: "bg-primary/10 text-primary",
value: stats?.scheduledPosts ?? 0,
label: "Publications planifiées",
change: stats?.scheduledPostsChange,
trending: stats?.scheduledPostsTrending,
changeLabel: "vs mois dernier",
href: "/calendar",
},
{
icon: Users,
iconColor: "from-purple-500 to-purple-600",
title: stats?.connectedPages ?? 0,
subtitle: "Pages connectées",
info: "Facebook • Instagram",
icon: Link2,
tone: "bg-info/10 text-info",
value: stats?.connectedPages ?? 0,
label: "Comptes connectés",
info: "Facebook · Instagram · TikTok",
href: "/pages",
},
{
icon: Bot,
iconColor: "from-green-500 to-green-600",
title: stats?.aiTextsGenerated ?? 0,
subtitle: "Textes générés par IA",
icon: Sparkles,
tone: "bg-brand-accent/15 text-brand-accent",
value: stats?.aiTextsGenerated ?? 0,
label: "Textes générés par l'IA",
change: stats?.aiTextsChange,
trending: stats?.aiTextsTrending,
changeLabel: "vs hier",
},
{
icon: Images,
iconColor: "from-cyan-500 to-cyan-600",
title: stats?.mediaStored ?? 0,
subtitle: "Médias stockés",
info: "Images • Vidéos",
tone: "bg-success/10 text-success",
value: stats?.mediaStored ?? 0,
label: "Médias stockés",
info: "Images · Vidéos",
href: "/media",
},
];
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-card rounded-2xl border border-border p-6 shadow-sm">
<div className="skeleton h-12 w-12 rounded-xl mb-4" />
<div className="skeleton h-10 w-20 mb-2" />
<div className="skeleton h-4 w-36" />
<div key={i} className="rounded-xl border bg-card p-4 shadow-soft sm:p-5">
<div className="skeleton mb-4 h-10 w-10 rounded-lg" />
<div className="skeleton mb-2 h-7 w-16 rounded" />
<div className="skeleton h-4 w-28 rounded" />
</div>
))}
</div>
@@ -67,45 +72,45 @@ export default function StatsCards() {
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
{statCards.map((stat, index) => {
const Icon = stat.icon;
const TrendIcon = stat.trending === "up" ? TrendingUp : TrendingDown;
return (
<div
key={index}
className="group bg-card rounded-2xl border border-border p-6 hover:border-primary/30 transition-all cursor-pointer card-hover shadow-sm"
data-testid={`stat-card-${index}`}
>
<div className="flex items-start justify-between mb-6">
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${stat.iconColor} flex items-center justify-center shadow-lg`}>
<Icon className="text-white w-7 h-7" />
</div>
const body = (
<>
<div className={cn("mb-4 flex h-10 w-10 items-center justify-center rounded-lg", stat.tone)}>
<Icon className="h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-4xl font-bold text-foreground" data-testid={`stat-value-${index}`}>
{stat.title}
</h3>
<p className="text-sm text-muted-foreground font-medium">{stat.subtitle}</p>
</div>
{stat.change && (
<div className="mt-4 flex items-center gap-2">
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg ${stat.trending === "up" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"}`}>
<TrendIcon className="w-3 h-3" />
<span className="text-xs font-semibold">{stat.change}</span>
</div>
<span className="text-xs text-muted-foreground">{stat.changeLabel}</span>
<p className="text-2xl font-semibold tabular-nums text-foreground sm:text-3xl" data-testid={`stat-value-${index}`}>
{stat.value}
</p>
<p className="mt-0.5 text-sm text-muted-foreground">{stat.label}</p>
{stat.change ? (
<div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
<span
className={cn(
"inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-medium",
stat.trending === "up" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive",
)}
>
<TrendIcon className="h-3 w-3" />
{stat.change}
</span>
<span className="text-muted-foreground">{stat.changeLabel}</span>
</div>
)}
{stat.info && (
<div className="mt-4">
<span className="text-xs text-muted-foreground font-medium">{stat.info}</span>
</div>
)}
) : stat.info ? (
<p className="mt-3 hidden text-xs text-muted-foreground sm:block">{stat.info}</p>
) : null}
</>
);
const cls = "block rounded-xl border bg-card p-4 shadow-soft sm:p-5";
return stat.href ? (
<Link key={index} href={stat.href} className={cn(cls, "card-hover")} data-testid={`stat-card-${index}`}>
{body}
</Link>
) : (
<div key={index} className={cls} data-testid={`stat-card-${index}`}>
{body}
</div>
);
})}
+51
View File
@@ -0,0 +1,51 @@
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes";
import { Monitor, Moon, Sun } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem storageKey="theme" disableTransitionOnChange>
{children}
</NextThemesProvider>
);
}
const OPTIONS = [
{ value: "light", label: "Clair", icon: Sun },
{ value: "dark", label: "Sombre", icon: Moon },
{ value: "system", label: "Système", icon: Monitor },
] as const;
/** Sélecteur segmenté clair / sombre / système. */
export function ThemeToggle({ className, compact = false }: { className?: string; compact?: boolean }) {
const { theme, setTheme } = useTheme();
const current = theme ?? "system";
return (
<div
role="radiogroup"
aria-label="Thème de l'interface"
className={cn("inline-flex w-full items-center gap-1 rounded-lg bg-muted p-1", className)}
>
{OPTIONS.map(({ value, label, icon: Icon }) => (
<button
key={value}
type="button"
role="radio"
aria-checked={current === value}
title={label}
onClick={() => setTheme(value)}
className={cn(
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground transition-colors",
current === value ? "bg-card text-foreground shadow-sm" : "hover:text-foreground",
)}
data-testid={`theme-${value}`}
>
<Icon className="h-3.5 w-3.5" />
{!compact && <span>{label}</span>}
</button>
))}
</div>
);
}
-52
View File
@@ -1,52 +0,0 @@
import { Menu, Search, ChevronLeft } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useLocation, Link } from "wouter";
import { Button } from "@/components/ui/button";
interface TopBarProps {
onMenuClick?: () => void;
hideMenuButton?: boolean;
}
export default function TopBar({ onMenuClick, hideMenuButton = false }: TopBarProps) {
const [location] = useLocation();
const showBackButton = location !== "/";
return (
<header className="bg-card/50 backdrop-blur-xl border-b border-border/50 sticky top-0 z-10">
<div className="flex items-center justify-between px-4 sm:px-6 lg:px-8 py-4 sm:py-5">
<div className="flex items-center gap-3 sm:gap-6 flex-1">
<div className="lg:hidden flex items-center gap-2">
{showBackButton ? (
<Link href="/">
<Button variant="ghost" size="icon" className="text-foreground hover:text-primary transition-all">
<ChevronLeft className="w-6 h-6" />
</Button>
</Link>
) : (
!hideMenuButton && (
<button
className="text-foreground hover:text-primary transition-all hover:scale-110 active:scale-95 p-2 hover:bg-primary/10 rounded-xl"
onClick={onMenuClick}
data-testid="button-menu"
aria-label="Toggle menu"
>
<Menu className="w-6 h-6" />
</button>
)
)}
</div>
<div className="flex-1 max-w-md hidden md:block">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Rechercher des publications, pages, médias..."
className="pl-10 bg-muted/30 border-muted focus:border-primary/50 rounded-xl"
/>
</div>
</div>
</div>
</div>
</header>
);
}
+6 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
@@ -15,6 +15,11 @@ const badgeVariants = cva(
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
success: "border-transparent bg-success/15 text-success",
warning: "border-transparent bg-warning/15 text-warning-foreground dark:text-warning",
info: "border-transparent bg-info/15 text-info",
danger: "border-transparent bg-destructive/15 text-destructive",
muted: "border-transparent bg-muted text-muted-foreground",
},
},
defaultVariants: {
+9 -7
View File
@@ -5,25 +5,27 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-all active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
brand:
"gradient-brand text-white shadow-md shadow-primary/20 hover:opacity-95 hover:shadow-lg hover:shadow-primary/25",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
"border border-input bg-card shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-11 px-4 py-2",
sm: "h-11 rounded-md px-3",
lg: "h-12 rounded-md px-8",
icon: "h-11 w-11",
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-lg px-6 text-[15px]",
icon: "h-10 w-10",
},
},
defaultVariants: {
+5 -5
View File
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
"rounded-xl border bg-card text-card-foreground shadow-soft",
className
)}
{...props}
@@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
className={cn("flex flex-col space-y-1.5 p-5 sm:p-6", className)}
{...props}
/>
))
@@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
"text-base font-semibold leading-tight tracking-tight",
className
)}
{...props}
@@ -60,7 +60,7 @@ const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
<div ref={ref} className={cn("p-5 pt-0 sm:p-6 sm:pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
@@ -70,7 +70,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
className={cn("flex items-center p-5 pt-0 sm:p-6 sm:pt-0", className)}
{...props}
/>
))
+1 -1
View File
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-10 w-full rounded-lg border border-input bg-card px-3 py-2 text-base shadow-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
+1 -1
View File
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-10 w-full items-center justify-between rounded-lg border border-input bg-card px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:border-primary focus:ring-2 focus:ring-ring/30 focus:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
+2 -2
View File
@@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
"inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
+1 -1
View File
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex min-h-[80px] w-full rounded-lg border border-input bg-card shadow-sm px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
+36
View File
@@ -0,0 +1,36 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useLocation } from "wouter";
import { apiRequest, AUTH_MUTATION, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
export interface Session {
id: string;
username: string;
role: string;
}
/** Session courante (mise en cache par react-query, partagée par toute l'app). */
export function useSession() {
const query = useQuery<Session>({
queryKey: ["/api/auth/session"],
retry: false,
});
return { ...query, session: query.data, isAdmin: query.data?.role === "admin" };
}
export function useLogout() {
const [, setLocation] = useLocation();
const { toast } = useToast();
return useMutation({
mutationKey: [AUTH_MUTATION, "logout"],
mutationFn: async () => {
const res = await apiRequest("POST", "/api/auth/logout", {});
return await res.json();
},
onSuccess: () => {
queryClient.clear();
setLocation("/login");
toast({ title: "Déconnecté", description: "À bientôt !" });
},
});
}
-72
View File
@@ -1,72 +0,0 @@
import { useState, useEffect } from 'react';
/**
* Hook to detect if the user is on a mobile device
* Uses both media query and user agent for robust detection
*
* @param breakpoint - The max-width breakpoint for mobile (default: 768px)
* @returns boolean - true if mobile, false if desktop
*/
export function useIsMobile(breakpoint: number = 768): boolean {
const [isMobile, setIsMobile] = useState<boolean>(() => {
// Initial detection (SSR-safe)
if (typeof window === 'undefined') return false;
// Check media query
const mediaMatch = window.matchMedia(`(max-width: ${breakpoint}px)`).matches;
// Check user agent for mobile devices
const userAgent = navigator.userAgent.toLowerCase();
const mobileKeywords = ['android', 'webos', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone'];
const isUserAgentMobile = mobileKeywords.some(keyword => userAgent.includes(keyword));
// Return true if either media query or user agent indicates mobile
return mediaMatch || isUserAgentMobile;
});
useEffect(() => {
// Create media query list
const mediaQueryList = window.matchMedia(`(max-width: ${breakpoint}px)`);
// Handler for media query changes
const handleChange = (e: MediaQueryListEvent) => {
setIsMobile(e.matches);
};
// Listen for changes (modern browsers)
if (mediaQueryList.addEventListener) {
mediaQueryList.addEventListener('change', handleChange);
} else {
// Fallback for older browsers
mediaQueryList.addListener(handleChange);
}
// Cleanup
return () => {
if (mediaQueryList.removeEventListener) {
mediaQueryList.removeEventListener('change', handleChange);
} else {
mediaQueryList.removeListener(handleChange);
}
};
}, [breakpoint]);
return isMobile;
}
/**
* Hook to detect device type with more granularity
* @returns object with device type information
*/
export function useDeviceType() {
const isMobile = useIsMobile(768);
const isTablet = useIsMobile(1024) && !useIsMobile(768);
const isDesktop = !useIsMobile(1024);
return {
isMobile,
isTablet,
isDesktop,
deviceType: isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop'
} as const;
}
+167 -150
View File
@@ -2,106 +2,132 @@
@tailwind components;
@tailwind utilities;
/*
* Social Flow — design tokens.
* Toutes les couleurs de l'interface passent par ces variables (triplets HSL)
* afin que les modes clair et sombre restent cohérents.
*/
:root {
--background: 0 0% 98%;
--foreground: 222 47% 11%;
--background: 210 40% 98%; /* #F8FAFC */
--foreground: 222 47% 11%; /* #0F172A */
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--primary: 215 85% 58%;
--primary: 243 75% 59%; /* #4F46E5 indigo */
--primary-foreground: 0 0% 100%;
--secondary: 265 75% 62%;
--secondary-foreground: 0 0% 100%;
--muted: 210 40% 96%;
--muted-foreground: 215 16% 47%;
--accent: 210 40% 96%;
--accent-foreground: 222 47% 11%;
--destructive: 0 85% 62%;
--secondary: 220 14% 94%;
--secondary-foreground: 222 47% 11%;
--brand-accent: 6 93% 68%; /* #F97362 corail */
--brand-accent-foreground: 0 0% 100%;
--muted: 220 14% 95%;
--muted-foreground: 215 16% 42%;
--accent: 226 100% 97%;
--accent-foreground: 243 60% 40%;
--destructive: 0 72% 51%; /* #DC2626 */
--destructive-foreground: 0 0% 100%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 215 85% 58%;
--success: 142 76% 48%;
--warning: 35 95% 58%;
--info: 195 85% 55%;
--chart-1: 215 85% 58%;
--chart-2: 265 75% 62%;
--chart-3: 142 76% 48%;
--chart-4: 195 85% 55%;
--chart-5: 35 95% 58%;
--success: 142 72% 37%; /* #16A34A */
--success-foreground: 0 0% 100%;
--warning: 38 92% 50%; /* #F59E0B */
--warning-foreground: 26 90% 14%;
--info: 200 98% 39%; /* #0284C7 */
--info-foreground: 0 0% 100%;
--facebook: 214 89% 52%; /* #1877F2 */
--instagram: 345 75% 57%; /* #E4405F */
--tiktok: 0 0% 7%;
--border: 220 13% 90%;
--input: 220 13% 87%;
--ring: 243 75% 59%;
--chart-1: 243 75% 59%;
--chart-2: 6 93% 68%;
--chart-3: 142 72% 37%;
--chart-4: 200 98% 39%;
--chart-5: 38 92% 50%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222 47% 11%;
--sidebar-primary: 215 85% 58%;
--sidebar-primary: 243 75% 59%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 210 40% 96%;
--sidebar-accent: 220 14% 96%;
--sidebar-accent-foreground: 222 47% 11%;
--sidebar-border: 214 32% 91%;
--sidebar-ring: 215 85% 58%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 243 75% 59%;
--radius: 0.75rem;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-serif: Georgia, 'Times New Roman', serif;
--font-mono: 'JetBrains Mono', 'Fira Code', Menlo, monospace;
}
.dark {
--background: 222 20% 8%;
--foreground: 0 0% 95%;
--card: 222 16% 15%;
--card-foreground: 0 0% 95%;
--popover: 222 18% 12%;
--popover-foreground: 0 0% 95%;
--primary: 215 85% 58%;
--primary-foreground: 0 0% 100%;
--secondary: 265 75% 62%;
--secondary-foreground: 0 0% 100%;
--muted: 222 12% 20%;
--muted-foreground: 222 10% 65%;
--accent: 222 18% 12%;
--accent-foreground: 0 0% 95%;
--destructive: 0 85% 62%;
--background: 224 47% 7%;
--foreground: 210 40% 96%;
--card: 224 40% 11%;
--card-foreground: 210 40% 96%;
--popover: 224 40% 12%;
--popover-foreground: 210 40% 96%;
--primary: 239 84% 70%;
--primary-foreground: 224 47% 9%;
--secondary: 223 30% 18%;
--secondary-foreground: 210 40% 96%;
--brand-accent: 6 93% 70%;
--brand-accent-foreground: 224 47% 9%;
--muted: 223 30% 16%;
--muted-foreground: 217 20% 68%;
--accent: 236 40% 20%;
--accent-foreground: 230 90% 88%;
--destructive: 0 84% 63%;
--destructive-foreground: 0 0% 100%;
--border: 222 12% 20%;
--input: 222 12% 20%;
--ring: 215 85% 58%;
--success: 142 76% 48%;
--warning: 35 95% 58%;
--info: 195 85% 55%;
--chart-1: 215 85% 58%;
--chart-2: 265 75% 62%;
--chart-3: 142 76% 48%;
--chart-4: 195 85% 55%;
--chart-5: 35 95% 58%;
--sidebar: 222 20% 8%;
--sidebar-foreground: 0 0% 95%;
--sidebar-primary: 215 85% 58%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 222 18% 12%;
--sidebar-accent-foreground: 0 0% 95%;
--sidebar-border: 222 12% 20%;
--sidebar-ring: 215 85% 58%;
--success: 142 64% 50%;
--success-foreground: 144 60% 8%;
--warning: 43 96% 56%;
--warning-foreground: 26 90% 10%;
--info: 199 89% 60%;
--info-foreground: 204 80% 10%;
--facebook: 214 89% 62%;
--instagram: 345 80% 65%;
--tiktok: 0 0% 96%;
--border: 223 25% 20%;
--input: 223 25% 24%;
--ring: 239 84% 70%;
--chart-1: 239 84% 70%;
--chart-2: 6 93% 70%;
--chart-3: 142 64% 50%;
--chart-4: 199 89% 60%;
--chart-5: 43 96% 56%;
--sidebar: 224 45% 9%;
--sidebar-foreground: 210 40% 96%;
--sidebar-primary: 239 84% 70%;
--sidebar-primary-foreground: 224 47% 9%;
--sidebar-accent: 223 30% 15%;
--sidebar-accent-foreground: 210 40% 96%;
--sidebar-border: 223 25% 16%;
--sidebar-ring: 239 84% 70%;
}
@layer base {
@@ -109,50 +135,75 @@
@apply border-border;
}
html {
-webkit-tap-highlight-color: transparent;
}
body {
@apply font-sans antialiased bg-background text-foreground;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
h1, h2, h3, h4, h5, h6 {
@apply font-semibold;
@apply font-semibold tracking-tight;
}
}
/* Custom scrollbar */
@layer utilities {
/* Dégradé de marque : réservé au logo et au bouton d'action principal. */
.gradient-brand {
background-image: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--brand-accent)));
}
/* Alias historique conservé pour les composants existants. */
.gradient-primary {
background-image: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--brand-accent)));
}
.gradient-text {
background-image: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--brand-accent)));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
.scrollbar-none {
scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
}
/* Barre de défilement discrète */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: hsl(var(--muted) / 0.5);
border-radius: 5px;
background: transparent;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, hsl(var(--primary)), hsl(var(--secondary)));
border-radius: 5px;
background: hsl(var(--muted-foreground) / 0.25);
border-radius: 9999px;
border: 2px solid hsl(var(--background));
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, hsl(var(--primary) / 0.9), hsl(var(--secondary) / 0.9));
background: hsl(var(--muted-foreground) / 0.4);
}
/* Smooth transitions */
* {
transition: background-color 200ms, border-color 200ms, color 200ms;
}
button, a {
transition: all 200ms;
}
/* Animation for cards */
/* Apparition douce des contenus */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
transform: translateY(6px);
}
to {
opacity: 1;
@@ -161,64 +212,23 @@ button, a {
}
.fade-in {
animation: fadeIn 150ms ease-out;
animation: fadeIn 180ms ease-out;
}
/* Gradient utilities */
.gradient-primary {
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--secondary)));
}
.gradient-text {
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--secondary)));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Card hover effect */
/* Survol des cartes cliquables */
.card-hover {
transition: transform 200ms, box-shadow 200ms;
transition: transform 150ms, box-shadow 150ms, border-color 150ms;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px hsl(var(--primary) / 0.12);
transform: translateY(-1px);
border-color: hsl(var(--primary) / 0.35);
box-shadow: 0 8px 20px -8px hsl(var(--primary) / 0.25);
}
/* Platform badges */
.badge-facebook {
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
border: 1px solid hsl(var(--primary) / 0.25);
}
.badge-instagram {
background: hsl(var(--secondary) / 0.1);
color: hsl(var(--secondary));
border: 1px solid hsl(var(--secondary) / 0.25);
}
/* Status indicators */
.status-scheduled {
color: hsl(var(--primary));
}
.status-published {
color: hsl(var(--success));
}
.status-failed {
color: hsl(var(--destructive));
}
.status-draft {
color: hsl(var(--muted-foreground));
}
/* Skeleton loader */
/* Squelette de chargement */
.skeleton {
background: linear-gradient(90deg, hsl(var(--muted)) 25%, hsl(var(--accent)) 50%, hsl(var(--muted)) 75%);
background: linear-gradient(90deg, hsl(var(--muted)) 25%, hsl(var(--secondary)) 50%, hsl(var(--muted)) 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@@ -227,3 +237,10 @@ button, a {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.fade-in,
.skeleton {
animation: none;
}
}
-38
View File
@@ -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
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
}
+20 -35
View File
@@ -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>
);
}
+44 -31
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
-42
View File
@@ -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>
);
}
-38
View File
@@ -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>
);
}
-2
View File
@@ -1,2 +0,0 @@
import AudioAdmin from "../audio-admin";
export default AudioAdmin;
-42
View File
@@ -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>
);
}
-119
View File
@@ -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>
);
}
-160
View File
@@ -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>
);
}
-645
View File
@@ -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>
);
}
-42
View File
@@ -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>
);
}
-800
View File
@@ -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>
);
}
-657
View File
@@ -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>
);
}
-533
View File
@@ -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>
);
}
-585
View File
@@ -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>
);
}
-240
View File
@@ -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>
);
}
-589
View File
@@ -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>
);
}
+17 -15
View File
@@ -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>
);
}
-40
View File
@@ -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>
);
}
+54 -32
View File
@@ -11,55 +11,74 @@ export default {
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
DEFAULT: "hsl(var(--card) / <alpha-value>)",
foreground: "hsl(var(--card-foreground) / <alpha-value>)",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
foreground: "hsl(var(--popover-foreground) / <alpha-value>)",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground) / <alpha-value>)",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
success: {
DEFAULT: "hsl(var(--success) / <alpha-value>)",
foreground: "hsl(var(--success-foreground) / <alpha-value>)",
},
warning: {
DEFAULT: "hsl(var(--warning) / <alpha-value>)",
foreground: "hsl(var(--warning-foreground) / <alpha-value>)",
},
info: {
DEFAULT: "hsl(var(--info) / <alpha-value>)",
foreground: "hsl(var(--info-foreground) / <alpha-value>)",
},
"brand-accent": {
DEFAULT: "hsl(var(--brand-accent) / <alpha-value>)",
foreground: "hsl(var(--brand-accent-foreground) / <alpha-value>)",
},
facebook: "hsl(var(--facebook) / <alpha-value>)",
instagram: "hsl(var(--instagram) / <alpha-value>)",
tiktok: "hsl(var(--tiktok) / <alpha-value>)",
border: "hsl(var(--border) / <alpha-value>)",
input: "hsl(var(--input) / <alpha-value>)",
ring: "hsl(var(--ring) / <alpha-value>)",
chart: {
"1": "hsl(var(--chart-1))",
"2": "hsl(var(--chart-2))",
"3": "hsl(var(--chart-3))",
"4": "hsl(var(--chart-4))",
"5": "hsl(var(--chart-5))",
"1": "hsl(var(--chart-1) / <alpha-value>)",
"2": "hsl(var(--chart-2) / <alpha-value>)",
"3": "hsl(var(--chart-3) / <alpha-value>)",
"4": "hsl(var(--chart-4) / <alpha-value>)",
"5": "hsl(var(--chart-5) / <alpha-value>)",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
DEFAULT: "hsl(var(--sidebar) / <alpha-value>)",
foreground: "hsl(var(--sidebar-foreground) / <alpha-value>)",
primary: "hsl(var(--sidebar-primary) / <alpha-value>)",
"primary-foreground": "hsl(var(--sidebar-primary-foreground) / <alpha-value>)",
accent: "hsl(var(--sidebar-accent) / <alpha-value>)",
"accent-foreground": "hsl(var(--sidebar-accent-foreground) / <alpha-value>)",
border: "hsl(var(--sidebar-border) / <alpha-value>)",
ring: "hsl(var(--sidebar-ring) / <alpha-value>)",
},
},
fontFamily: {
@@ -85,6 +104,9 @@ export default {
},
},
},
boxShadow: {
soft: "0 1px 2px 0 rgb(15 23 42 / 0.04), 0 1px 3px 0 rgb(15 23 42 / 0.06)",
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",