import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { motion } from 'framer-motion' import { BookOpen, Bug, Clock, Folder, KanbanSquare, Keyboard, LayoutDashboard, LogOut, Monitor, Moon, PanelLeftClose, PanelLeftOpen, Pencil, Plus, Search, ShieldCheck, Sun, Users, WifiOff } from 'lucide-react' import { useApp } from '../lib/AppContext' import { useFeedback } from '../lib/feedback' import { humanize } from '../lib/db' import { Theme, ResolvedTheme, getTheme, resolveTheme, setTheme, watchSystemTheme } from '../lib/theme' import { Page, plural } from '../lib/types' import { currentVersion } from '../lib/updater' import { Avatar } from './people' import { Button, IconButton, Kbd } from './ui' import { Modal, ModalActions } from './Modal' import { Select } from './Select' import NotificationBell from './NotificationBell' import UpdateBanner, { UpdateCheckRow } from './UpdateBanner' import CommandPalette from './CommandPalette' import ProjectModal from './ProjectModal' import Logo from './Logo' export type { Page } export const NAV: { id: Page label: string short: string icon: typeof LayoutDashboard adminOnly?: boolean }[] = [ { id: 'dashboard', label: 'Tableau de bord', short: 'Accueil', icon: LayoutDashboard }, { id: 'kanban', label: 'Tâches', short: 'Tâches', icon: KanbanSquare }, { id: 'planning', label: 'Feuille de temps', short: 'Temps', icon: Clock }, { id: 'bugs', label: 'Bugs', short: 'Bugs', icon: Bug }, { id: 'docs', label: 'Documentation', short: 'Doc', icon: BookOpen }, { id: 'members', label: 'Équipe', short: 'Équipe', icon: Users }, { id: 'admin', label: 'Administration', short: 'Admin', icon: ShieldCheck, adminOnly: true } ] // --------------------------------------------------------------------- // Barre supérieure partagée // // Avant, l'application empilait DEUX barres : un bandeau global de 48 px // quasi vide, plus l'en-tête propre à chaque page. Désormais il n'y en a // qu'une : le titre vient de la navigation, et chaque page y injecte ses // actions via (un portal) et son sous-titre via // usePageSubtitle(). // --------------------------------------------------------------------- interface PageChrome { slot: HTMLElement | null setSubtitle: (s: string | null) => void } const ChromeCtx = createContext({ slot: null, setSubtitle: () => undefined }) /** Boutons d'action de la page courante, rendus dans la barre supérieure. */ export function PageActions({ children }: { children: ReactNode }): JSX.Element | null { const { slot } = useContext(ChromeCtx) if (!slot) return null return createPortal(<>{children}, slot) } /** * Sous-titre contextuel de la page (« 3 bugs actifs », la semaine en cours…). * * ⚠️ Passe une CHAÎNE (ou une valeur mémoïsée) : la valeur est stockée dans un * état, donc un élément JSX recréé à chaque rendu ferait boucler la page. */ export function usePageSubtitle(subtitle: string | null): void { const { setSubtitle } = useContext(ChromeCtx) useEffect(() => { setSubtitle(subtitle) return () => setSubtitle(null) }, [subtitle, setSubtitle]) } // --------------------------------------------------------------------- const SIDEBAR_KEY = 'gamedev-tracker-sidebar' export default function Layout({ children }: { children: ReactNode }): JSX.Element { const { page, setPage, profile, projects, currentProject, online, signOut } = useApp() const [collapsed, setCollapsed] = useState(() => localStorage.getItem(SIDEBAR_KEY) === '1') const [showNewProject, setShowNewProject] = useState(false) const [showHelp, setShowHelp] = useState(false) const [showPalette, setShowPalette] = useState(false) const [editProfile, setEditProfile] = useState(false) const [theme, setThemeState] = useState(getTheme()) const [resolved, setResolved] = useState(() => resolveTheme(getTheme())) const [slot, setSlot] = useState(null) const [subtitle, setSubtitle] = useState(null) const nav = useMemo(() => NAV.filter((n) => !n.adminOnly || profile?.is_admin), [profile?.is_admin]) const active = nav.find((n) => n.id === page) ?? nav[0] const toggleSidebar = useCallback(() => { setCollapsed((c) => { localStorage.setItem(SIDEBAR_KEY, c ? '0' : '1') return !c }) }, []) /** clair → sombre → système → clair */ const cycleTheme = useCallback(() => { setThemeState((cur) => { const next: Theme = cur === 'light' ? 'dark' : cur === 'dark' ? 'system' : 'light' setResolved(setTheme(next)) return next }) }, []) useEffect(() => watchSystemTheme(setResolved), []) // Raccourcis globaux. Tous avec Ctrl/Cmd : une touche seule changeait le // thème ou la page par simple frappe involontaire. useEffect(() => { const onKey = (e: globalThis.KeyboardEvent): void => { const mod = e.ctrlKey || e.metaKey const el = document.activeElement as HTMLElement | null const typing = !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable) if (mod && e.key.toLowerCase() === 'k') { e.preventDefault() setShowPalette(true) return } if (!typing && e.key === '/') { e.preventDefault() setShowPalette(true) return } if (mod && e.key.toLowerCase() === 'b') { e.preventDefault() toggleSidebar() return } if (!typing && e.key === '?') { e.preventDefault() setShowHelp(true) return } if (mod && !e.shiftKey && /^[1-9]$/.test(e.key)) { const target = nav[Number(e.key) - 1] if (target) { e.preventDefault() setPage(target.id) } } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [nav, setPage, toggleSidebar]) const ThemeIcon = theme === 'system' ? Monitor : resolved === 'dark' ? Moon : Sun const themeLabel = theme === 'system' ? 'Thème : système' : theme === 'dark' ? 'Thème : sombre' : 'Thème : clair' const chrome = useMemo(() => ({ slot, setSubtitle }), [slot]) return (
{/* ------------------------------ Barre latérale ------------------------------ */} {/* ------------------------------ Contenu ------------------------------ */}
{!online && (
Hors ligne — tes modifications ne seront pas enregistrées tant que la connexion n'est pas revenue.
)} {/* UNE seule barre supérieure pour toute l'application */}
: } onClick={toggleSidebar} />

{active?.label}

{subtitle ?? currentProject?.name ?? 'Aucun projet'}

{/* Actions injectées par la page courante */}
} onClick={() => setShowPalette(true)} className="lg:hidden" /> } onClick={() => setShowHelp(true)} />
{currentProject || page === 'admin' || page === 'members' ? ( children ) : ( setShowNewProject(true)} /> )}
{showNewProject && setShowNewProject(false)} />} {editProfile && profile && setEditProfile(false)} />} {showHelp && setShowHelp(false)} />} {showPalette && setShowPalette(false)} />}
) } // --------------------------------------------------------------------- // Sélecteur de projet // (l'ancien menu maison ne se fermait ni au clic extérieur ni à Échap) // --------------------------------------------------------------------- function ProjectSwitcher({ collapsed, onCreate }: { collapsed: boolean onCreate: () => void }): JSX.Element | null { const { projects, currentProject, selectProject, projectsLoading } = useApp() if (collapsed) { return (
} onClick={onCreate} />
) } if (projectsLoading) return
const NEW = '__new__' return (