SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
4 h agolast push
JavaScript 56.7% TypeScript 42.6%
5.7 KB · 172 lines tsx
Raw Blame History
1"use client";23import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";4import { PREF_KEYS } from "@/lib/prefs";56export interface Crumb {7  id: string;8  name: string;9}1011export interface ShellHeader {12  title?: string;13  /** Fil d'Ariane (pages Fichiers / Dossier) — l'accueil "Fichiers" est ajouté automatiquement. */14  crumbs?: Crumb[];15}1617interface ShellContextType {18  sidebarCollapsed: boolean;19  toggleSidebar: () => void;20  mobileNavOpen: boolean;21  setMobileNavOpen: (v: boolean) => void;22  commandOpen: boolean;23  openCommand: (initialQuery?: string) => void;24  closeCommand: () => void;25  commandQuery: string;26  prefsOpen: boolean;27  setPrefsOpen: (v: boolean) => void;28  header: ShellHeader;29  setHeader: (h: ShellHeader) => void;30  /** Utilisateur de session (chargé une fois pour tout le shell) */31  session: SessionUser | null;32  isAdmin: boolean;33  refreshSession: () => void;34}3536export interface SessionUser {37  id: string;38  name?: string | null;39  email?: string | null;40  role?: string;41  avatarPath?: string | null;42  twoFactorEnabled?: boolean;43}4445const ShellContext = createContext<ShellContextType | null>(null);4647export function useShell(): ShellContextType {48  const ctx = useContext(ShellContext);49  if (!ctx) {50    // Contexte absent (page hors AppShell) → no-op sûr51    return {52      sidebarCollapsed: false, toggleSidebar: () => {},53      mobileNavOpen: false, setMobileNavOpen: () => {},54      commandOpen: false, openCommand: () => {}, closeCommand: () => {}, commandQuery: "",55      prefsOpen: false, setPrefsOpen: () => {},56      header: {}, setHeader: () => {},57      session: null, isAdmin: false, refreshSession: () => {},58    };59  }60  return ctx;61}6263/** Déclare l'en-tête de la barre supérieure pour la page courante. */64export function useShellHeader(header: ShellHeader) {65  const { setHeader } = useShell();66  const key = JSON.stringify(header);67  useEffect(() => {68    setHeader(header);69    return () => setHeader({});70    // eslint-disable-next-line react-hooks/exhaustive-deps71  }, [key]);72}7374export function ShellProvider({ children }: { children: React.ReactNode }) {75  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);76  const [mobileNavOpen, setMobileNavOpen] = useState(false);77  const [commandOpen, setCommandOpen] = useState(false);78  const [commandQuery, setCommandQuery] = useState("");79  const [prefsOpen, setPrefsOpen] = useState(false);80  const [header, setHeader] = useState<ShellHeader>({});81  const [session, setSession] = useState<SessionUser | null>(null);8283  useEffect(() => {84    try {85      setSidebarCollapsed(localStorage.getItem(PREF_KEYS.sidebar) === "collapsed");86    } catch {}87  }, []);8889  const refreshSession = useCallback(() => {90    fetch("/api/auth/session")91      .then((r) => r.json())92      .then((d) => {93        if (d?.isLoggedIn) {94          setSession({ id: d.userId, name: d.name, email: d.email, role: d.role || d.userRole, avatarPath: d.avatarPath, twoFactorEnabled: d.twoFactorEnabled });95        } else setSession(null);96      })97      .catch(() => {});98  }, []);99100  useEffect(() => { refreshSession(); }, [refreshSession]);101102  const toggleSidebar = useCallback(() => {103    setSidebarCollapsed((c) => {104      const next = !c;105      try { localStorage.setItem(PREF_KEYS.sidebar, next ? "collapsed" : "expanded"); } catch {}106      return next;107    });108  }, []);109110  const openCommand = useCallback((q = "") => {111    setCommandQuery(q);112    setCommandOpen(true);113  }, []);114  const closeCommand = useCallback(() => setCommandOpen(false), []);115116  // Raccourcis globaux : ⌘K / Ctrl+K, "/" hors champ de saisie, ⌘, pour les préférences117  useEffect(() => {118    const onKey = (e: KeyboardEvent) => {119      const t = e.target as HTMLElement | null;120      const typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable);121      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {122        e.preventDefault();123        setCommandOpen((o) => !o);124        return;125      }126      if ((e.metaKey || e.ctrlKey) && e.key === ",") {127        e.preventDefault();128        setPrefsOpen(true);129        return;130      }131      if (!typing && e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {132        e.preventDefault();133        openCommand("");134      }135    };136    window.addEventListener("keydown", onKey);137    // Événements custom (boutons, autres composants)138    const onOpen = (e: Event) => openCommand((e as CustomEvent<string>).detail || "");139    const onPrefs = () => setPrefsOpen(true);140    window.addEventListener("spb:command", onOpen);141    window.addEventListener("spb:prefs", onPrefs);142    return () => {143      window.removeEventListener("keydown", onKey);144      window.removeEventListener("spb:command", onOpen);145      window.removeEventListener("spb:prefs", onPrefs);146    };147  }, [openCommand]);148149  const value = useMemo<ShellContextType>(() => ({150    sidebarCollapsed, toggleSidebar,151    mobileNavOpen, setMobileNavOpen,152    commandOpen, openCommand, closeCommand, commandQuery,153    prefsOpen, setPrefsOpen,154    header, setHeader,155    session, isAdmin: session?.role === "admin", refreshSession,156  }), [sidebarCollapsed, toggleSidebar, mobileNavOpen, commandOpen, openCommand, closeCommand, commandQuery, prefsOpen, header, session, refreshSession]);157158  return <ShellContext.Provider value={value}>{children}</ShellContext.Provider>;159}160161/** Ouvre la palette de commandes depuis n'importe où (sans contexte). */162export function openCommandPalette(query = "") {163  window.dispatchEvent(new CustomEvent("spb:command", { detail: query }));164}165export function openPreferences() {166  window.dispatchEvent(new Event("spb:prefs"));167}168/** Bascule le panneau Assistant IA (écouté par AiPanel). */169export function toggleAiPanel() {170  window.dispatchEvent(new Event("spb:ai-toggle"));171}172