584 lines
21 KiB
TypeScript
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 }
|