TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Coquille de l'application : barre latérale (desktop), navigation basse (mobile),3// bannière d'avertissement admin initial, raccourcis clavier globaux.4import Link from "next/link";5import { usePathname, useRouter } from "next/navigation";6import { useEffect, useState } from "react";7import {8 GraduationCap, Library, LogOut, MessageSquareText, Settings, ShieldAlert, SlidersHorizontal,9} from "lucide-react";10import { ImmbotLogo, ImmbotMark, UqoLogo } from "@/components/logo";11import { ThemeToggle } from "@/components/theme-toggle";12import { cn } from "@/components/ui";1314export type ShellUser = {15 id: number;16 username: string;17 displayName: string;18 role: "student" | "instructor" | "admin";19 isInitialAdmin: boolean;20};2122export function AppShell({23 user,24 courses,25 children,26}: {27 user: ShellUser;28 courses: { code: string; title: string; color: string }[];29 children: React.ReactNode;30}) {31 const pathname = usePathname();32 const router = useRouter();33 const [initialPasswordActive] = useState(user.isInitialAdmin);3435 useEffect(() => {36 const onKey = (e: KeyboardEvent) => {37 if ((e.metaKey || e.ctrlKey) && e.key === "k") {38 e.preventDefault();39 router.push("/chat?new=1");40 }41 if ((e.metaKey || e.ctrlKey) && e.key === "j") {42 e.preventDefault();43 router.push("/apprendre");44 }45 };46 window.addEventListener("keydown", onKey);47 return () => window.removeEventListener("keydown", onKey);48 }, [router]);4950 async function logout() {51 await fetch("/api/auth/logout", { method: "POST" });52 router.push("/connexion");53 router.refresh();54 }5556 const nav = [57 { href: "/chat", label: "Chat", icon: MessageSquareText },58 { href: "/apprendre", label: "Apprendre", icon: GraduationCap },59 { href: "/bibliotheque", label: "Bibliothèque", icon: Library },60 { href: "/parametres", label: "Paramètres", icon: Settings },61 ...(user.role !== "student" ? [{ href: "/admin", label: "Administration", icon: SlidersHorizontal }] : []),62 ];63 const isActive = (href: string) => pathname === href || pathname.startsWith(href + "/");6465 return (66 <div className="min-h-dvh bg-app flex">67 {/* Barre latérale desktop */}68 <aside className="hidden md:flex flex-col w-60 shrink-0 bg-sidebar border-r border-app sticky top-0 h-dvh">69 <div className="px-4 h-16 flex items-center border-b border-app">70 <Link href="/chat"><ImmbotLogo size={27} /></Link>71 </div>72 <nav className="flex-1 px-2.5 py-3 space-y-0.5 overflow-y-auto">73 {nav.map((item) => (74 <Link75 key={item.href}76 href={item.href}77 className={cn(78 "flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors",79 isActive(item.href)80 ? "bg-brand-100 dark:bg-brand-900/60 text-brand-700 dark:text-brand-200"81 : "text-muted hover:text-fg hover:bg-surface-2 dark:hover:bg-brand-900/30"82 )}83 >84 <item.icon size={17} />85 {item.label}86 </Link>87 ))}88 <div className="pt-4 mt-3 border-t border-app">89 <p className="px-3 pb-1.5 text-[11px] font-semibold text-muted uppercase tracking-wider">Mes cours</p>90 {courses.map((c) => (91 <Link92 key={c.code}93 href={`/apprendre/${c.code.toLowerCase()}`}94 className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] text-muted hover:text-fg hover:bg-surface-2 dark:hover:bg-brand-900/30 transition-colors"95 >96 <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: c.color }} />97 <span className="truncate">{c.code}</span>98 </Link>99 ))}100 </div>101 </nav>102 <div className="px-4 py-3 border-t border-app">103 <UqoLogo height={30} className="opacity-90 dark:invert dark:opacity-70" />104 </div>105 <div className="p-3 border-t border-app flex items-center justify-between gap-2">106 <div className="flex items-center gap-2.5 min-w-0">107 <div className="w-8 h-8 rounded-full bg-brand-600 text-white text-[13px] font-semibold flex items-center justify-center shrink-0">108 {user.displayName.slice(0, 1).toUpperCase()}109 </div>110 <div className="min-w-0">111 <p className="text-[13px] font-medium text-fg truncate">{user.displayName}</p>112 <p className="text-[11px] text-muted capitalize">{user.role === "student" ? "Étudiant·e" : user.role === "admin" ? "Admin" : "Professeur"}</p>113 </div>114 </div>115 <div className="flex items-center">116 <ThemeToggle />117 <button onClick={logout} title="Déconnexion" aria-label="Déconnexion" className="p-2 rounded-lg text-muted hover:text-fg hover:bg-surface-2 dark:hover:bg-brand-900/40">118 <LogOut size={16} />119 </button>120 </div>121 </div>122 </aside>123124 {/* Contenu */}125 <div className="flex-1 flex flex-col min-w-0 pb-16 md:pb-0">126 {initialPasswordActive && (127 <div className="bg-amber-500/15 border-b border-amber-500/30 px-4 py-2 flex items-center gap-2 text-[13px] text-amber-800 dark:text-amber-300">128 <ShieldAlert size={15} className="shrink-0" />129 <span>130 Compte d'amorçage actif — remplacez le mot de passe initial et créez votre compte personnel, puis131 désactivez ce compte en production (<code className="font-mono">DISABLE_INITIAL_ADMIN=true</code>).132 </span>133 </div>134 )}135 {children}136 </div>137138 {/* Navigation basse mobile */}139 <nav className="md:hidden fixed bottom-0 inset-x-0 z-40 bg-card/95 backdrop-blur border-t border-app flex justify-around py-1.5 pb-[max(0.375rem,env(safe-area-inset-bottom))]">140 {nav.slice(0, 4).map((item) => (141 <Link142 key={item.href}143 href={item.href}144 className={cn(145 "flex flex-col items-center gap-0.5 px-3 py-1 rounded-lg text-[10.5px] font-medium",146 isActive(item.href) ? "text-brand-600 dark:text-brand-300" : "text-muted"147 )}148 >149 <item.icon size={19} />150 {item.label}151 </Link>152 ))}153 {user.role !== "student" && (154 <Link href="/admin" className={cn("flex flex-col items-center gap-0.5 px-3 py-1 text-[10.5px] font-medium", isActive("/admin") ? "text-brand-600 dark:text-brand-300" : "text-muted")}>155 <SlidersHorizontal size={19} />156 Admin157 </Link>158 )}159 </nav>160 </div>161 );162}163164export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: React.ReactNode }) {165 return (166 <div className="flex flex-wrap items-center justify-between gap-3 mb-6">167 <div>168 <h1 className="text-xl font-bold text-fg flex items-center gap-2.5">169 <ImmbotMark size={22} className="md:hidden" />170 {title}171 </h1>172 {subtitle && <p className="text-sm text-muted mt-0.5">{subtitle}</p>}173 </div>174 {actions}175 </div>176 );177}178