Files
gestion_projet/src/components/Layout.tsx
T
2026-08-10 22:06:49 +02:00

584 lines
21 KiB
TypeScript

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 <PageActions> (un portal) et son sous-titre via
// usePageSubtitle().
// ---------------------------------------------------------------------
interface PageChrome {
slot: HTMLElement | null
setSubtitle: (s: string | null) => void
}
const ChromeCtx = createContext<PageChrome>({ 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<Theme>(getTheme())
const [resolved, setResolved] = useState<ResolvedTheme>(() => resolveTheme(getTheme()))
const [slot, setSlot] = useState<HTMLElement | null>(null)
const [subtitle, setSubtitle] = useState<string | null>(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<PageChrome>(() => ({ slot, setSubtitle }), [slot])
return (
<ChromeCtx.Provider value={chrome}>
<div className="flex h-full">
{/* ------------------------------ Barre latérale ------------------------------ */}
<aside
className={`flex shrink-0 flex-col border-r border-border bg-sidebar transition-[width] duration-200 ease-smooth ${
collapsed ? 'w-[3.75rem]' : 'w-60'
}`}
>
<div className={`flex h-14 items-center gap-2.5 ${collapsed ? 'justify-center px-2' : 'px-4'}`}>
<Logo size={26} />
{!collapsed && <span className="truncate font-semibold text-ink">GameDev Tracker</span>}
</div>
<ProjectSwitcher collapsed={collapsed} onCreate={() => setShowNewProject(true)} />
<nav className="mt-3 flex-1 space-y-0.5 px-2" aria-label="Navigation principale">
{nav.map((n) => {
const on = page === n.id
return (
<button
key={n.id}
type="button"
onClick={() => setPage(n.id)}
title={collapsed ? n.label : undefined}
aria-current={on ? 'page' : undefined}
className={`relative flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors ${
collapsed ? 'justify-center' : ''
} ${on ? 'text-accent' : 'text-muted hover:bg-hover hover:text-ink'}`}
>
{on && (
<motion.span
layoutId="nav-active"
className="absolute inset-0 rounded-lg bg-accent/12 ring-1 ring-inset ring-accent/25"
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
/>
)}
<n.icon size={18} className="relative z-10 shrink-0" />
{!collapsed && <span className="relative z-10 truncate">{n.label}</span>}
</button>
)
})}
</nav>
{/* Pied : utilisateur */}
<div className="border-t border-border p-2">
{collapsed ? (
<div className="flex flex-col items-center gap-1">
<button
type="button"
onClick={() => setEditProfile(true)}
title={`${profile?.full_name ?? ''} — modifier mon profil`}
className="rounded-full ring-offset-2 ring-offset-sidebar hover:ring-2 hover:ring-accent"
>
<Avatar profile={profile} size={28} />
</button>
<IconButton size="sm" label={themeLabel} icon={<ThemeIcon size={16} />} onClick={cycleTheme} />
<IconButton
size="sm"
tone="danger"
label="Se déconnecter"
icon={<LogOut size={16} />}
onClick={signOut}
/>
</div>
) : (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setEditProfile(true)}
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg p-1 text-left transition-colors hover:bg-hover"
>
<Avatar profile={profile} size={30} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ink">
{profile?.full_name}
</span>
<span className="block truncate text-xs text-subtle">Modifier mon profil</span>
</span>
<Pencil size={14} className="shrink-0 text-subtle" />
</button>
<IconButton size="sm" label={themeLabel} icon={<ThemeIcon size={17} />} onClick={cycleTheme} />
<IconButton
size="sm"
tone="danger"
label="Se déconnecter"
icon={<LogOut size={17} />}
onClick={signOut}
/>
</div>
)}
</div>
</aside>
{/* ------------------------------ Contenu ------------------------------ */}
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
<UpdateBanner />
{!online && (
<div
role="status"
className="flex shrink-0 items-center justify-center gap-2 bg-amber-500/15 py-1.5 text-xs font-medium text-amber-700 dark:text-amber-300"
>
<WifiOff size={13} />
Hors ligne tes modifications ne seront pas enregistrées tant que la connexion n'est pas revenue.
</div>
)}
{/* UNE seule barre supérieure pour toute l'application */}
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-panel px-4">
<IconButton
size="sm"
label={collapsed ? 'Déployer le menu (Ctrl+B)' : 'Replier le menu (Ctrl+B)'}
icon={collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
onClick={toggleSidebar}
/>
<div className="min-w-0">
<h1 className="truncate text-[15px] font-semibold leading-tight text-ink">
{active?.label}
</h1>
<p className="truncate text-xs leading-tight text-subtle">
{subtitle ?? currentProject?.name ?? 'Aucun projet'}
</p>
</div>
<div className="ml-auto flex items-center gap-2">
{/* Actions injectées par la page courante */}
<div ref={setSlot} className="flex items-center gap-2" />
<span className="mx-1 h-6 w-px bg-border" />
<button
type="button"
onClick={() => setShowPalette(true)}
className="hidden items-center gap-2 rounded-lg border border-border bg-panel2 py-1.5 pl-2.5 pr-2 text-xs text-subtle transition-colors hover:border-border-strong hover:text-muted lg:flex"
>
<Search size={14} />
Rechercher
<Kbd>Ctrl K</Kbd>
</button>
<IconButton
size="sm"
label="Rechercher (Ctrl+K)"
icon={<Search size={17} />}
onClick={() => setShowPalette(true)}
className="lg:hidden"
/>
<IconButton
size="sm"
label="Raccourcis clavier (?)"
icon={<Keyboard size={17} />}
onClick={() => setShowHelp(true)}
/>
<NotificationBell />
</div>
</header>
<div className="min-h-0 flex-1 overflow-hidden">
{currentProject || page === 'admin' || page === 'members' ? (
children
) : (
<NoProject
loading={projects.length === 0}
onCreate={() => setShowNewProject(true)}
/>
)}
</div>
</main>
{showNewProject && <ProjectModal mode="create" onClose={() => setShowNewProject(false)} />}
{editProfile && profile && <ProfileModalLazy onClose={() => setEditProfile(false)} />}
{showHelp && <ShortcutsModal nav={nav} onClose={() => setShowHelp(false)} />}
{showPalette && <CommandPalette onClose={() => setShowPalette(false)} />}
</div>
</ChromeCtx.Provider>
)
}
// ---------------------------------------------------------------------
// 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 (
<div className="flex justify-center px-2">
<IconButton
size="sm"
label={currentProject?.name ?? 'Aucun projet'}
icon={<Folder size={17} />}
onClick={onCreate}
/>
</div>
)
}
if (projectsLoading) return <div className="mx-3 h-[42px] skeleton" />
const NEW = '__new__'
return (
<div className="px-3">
<Select
ariaLabel="Changer de projet"
placeholder="Aucun projet"
className="!bg-panel"
value={currentProject?.id ?? ''}
onChange={(id) => {
if (id === NEW) {
onCreate()
return
}
const p = projects.find((x) => x.id === id)
if (p) selectProject(p)
}}
options={[
...projects.map((p) => ({
value: p.id,
label: p.name,
hint: p.description ?? undefined,
group: 'Projets'
})),
{ value: NEW, label: '+ Nouveau projet', group: 'Gérer' }
]}
/>
</div>
)
}
// ---------------------------------------------------------------------
function NoProject({ loading, onCreate }: { loading: boolean; onCreate: () => void }): JSX.Element {
const { projectsLoading, projectsError, refreshProjects } = useApp()
// Avant, « Aucun projet » clignotait pendant le chargement.
if (projectsLoading || (loading && !projectsError)) {
return (
<div className="flex h-full items-center justify-center">
<div className="w-72 space-y-3">
<div className="skeleton h-6 w-1/2" />
<div className="skeleton h-24 w-full" />
</div>
</div>
)
}
if (projectsError) {
return (
<div className="flex h-full items-center justify-center p-6">
<div className="max-w-md text-center">
<p className="mb-3 text-sm text-muted">{projectsError}</p>
<Button onClick={refreshProjects}>Réessayer</Button>
</div>
</div>
)
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
<Folder size={40} className="text-subtle" />
<div>
<p className="text-sm font-medium text-ink">Aucun projet pour le moment</p>
<p className="mt-1 max-w-sm text-sm text-muted">
Un projet regroupe les tâches, les bugs et le temps passé de ton jeu.
</p>
</div>
<Button variant="primary" icon={<Plus size={16} />} onClick={onCreate}>
Créer le premier projet
</Button>
</div>
)
}
// ---------------------------------------------------------------------
function ShortcutsModal({
nav,
onClose
}: {
nav: { id: Page; label: string }[]
onClose: () => void
}): JSX.Element {
const [version, setVersion] = useState<string | null>(null)
useEffect(() => {
currentVersion().then(setVersion)
}, [])
const groups: { title: string; rows: [string, string][] }[] = [
{
title: 'Général',
rows: [
['Ctrl K', 'Rechercher / palette de commandes'],
['/', 'Rechercher'],
['Ctrl B', 'Replier ou déployer le menu'],
['?', 'Afficher cette aide'],
['Échap', 'Fermer une fenêtre']
]
},
{
title: 'Navigation',
rows: nav.map((n, i): [string, string] => [`Ctrl ${i + 1}`, n.label])
},
{
title: 'Kanban',
rows: [
['Espace', 'Prendre / déposer une carte au clavier'],
['← →', 'Changer de colonne (carte prise)'],
['↑ ↓', 'Monter / descendre (carte prise)']
]
},
{
title: 'Écriture',
rows: [['Ctrl ⏎', 'Envoyer un commentaire']]
}
]
return (
<Modal title="Raccourcis clavier" onClose={onClose} footer={
<ModalActions>
<Button variant="primary" onClick={onClose}>Compris</Button>
</ModalActions>
}>
<div className="space-y-4">
{groups.map((g) => (
<section key={g.title}>
<h3 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-subtle">
{g.title}
</h3>
<ul className="divide-y divide-border">
{g.rows.map(([key, label]) => (
<li key={key + label} className="flex items-center justify-between gap-4 py-1.5 text-sm">
<span className="text-ink">{label}</span>
<span className="flex shrink-0 gap-1">
{key.split(' ').map((k) => (
<Kbd key={k}>{k}</Kbd>
))}
</span>
</li>
))}
</ul>
</section>
))}
</div>
<p className="mt-4 text-xs text-subtle">
Les raccourcis de navigation sont inactifs pendant la saisie de texte.
</p>
<div className="mt-4">
<UpdateCheckRow version={version} />
</div>
</Modal>
)
}
// ---------------------------------------------------------------------
// Édition du profil — importée à la demande pour ne pas alourdir le shell.
// ---------------------------------------------------------------------
function ProfileModalLazy({ onClose }: { onClose: () => void }): JSX.Element | null {
const { profile, refreshProfile } = useApp()
const { toast } = useFeedback()
const [Comp, setComp] = useState<null | React.ComponentType<{
profile: NonNullable<typeof profile>
onClose: () => void
onSaved: () => Promise<void>
}>>(null)
useEffect(() => {
import('./EditProfileModal')
.then((m) => setComp(() => m.default))
.catch((e) => {
toast(humanize(e), 'error')
onClose()
})
}, [onClose, toast])
if (!Comp || !profile) return null
return <Comp profile={profile} onClose={onClose} onSaved={refreshProfile} />
}
/** Petit utilitaire de libellé réexporté pour les pages. */
export { plural }