mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 23:18:12 +02:00
Refonte UI : design system, logo, shell responsive et pages simples
- Nouveaux tokens (indigo/corail, statuts, plateformes) et mode sombre - Logo et favicon Social Flow redessinés - AppShell partagé : sidebar groupée (desktop) + barre de navigation basse (mobile) - Suppression des pages mobiles dupliquées et des composants morts - Tableau de bord, connexion, 404, calendrier, médiathèque, IA, statistiques Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ADkrVcVFTT14hbB148HYS
This commit is contained in:
55 files changed
+1591
-6331
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user