SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
19 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
7.2 KB · 178 lines tsx
Raw Blame History
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