V0.3
This commit is contained in:
+583
-227
@@ -1,227 +1,583 @@
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
KanbanSquare,
|
||||
Clock,
|
||||
Bug,
|
||||
Users,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
LogOut,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Sun,
|
||||
Moon
|
||||
} from 'lucide-react'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { ROLE_LABELS } from '../lib/types'
|
||||
import { Theme, getTheme, setTheme } from '../lib/theme'
|
||||
import { Avatar, Modal } from './ui'
|
||||
import NotificationBell from './NotificationBell'
|
||||
import Logo from './Logo'
|
||||
|
||||
export type Page =
|
||||
| 'dashboard'
|
||||
| 'kanban'
|
||||
| 'planning'
|
||||
| 'bugs'
|
||||
| 'members'
|
||||
| 'admin'
|
||||
|
||||
const NAV: { id: Page; label: string; icon: typeof LayoutDashboard; adminOnly?: boolean }[] = [
|
||||
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
|
||||
{ id: 'kanban', label: 'Tâches (Kanban)', icon: KanbanSquare },
|
||||
{ id: 'planning', label: 'Feuille de temps', icon: Clock },
|
||||
{ id: 'bugs', label: 'Bugs', icon: Bug },
|
||||
{ id: 'members', label: 'Équipe', icon: Users },
|
||||
{ id: 'admin', label: 'Administration', icon: ShieldCheck, adminOnly: true }
|
||||
]
|
||||
|
||||
export default function Layout({
|
||||
page,
|
||||
setPage,
|
||||
children
|
||||
}: {
|
||||
page: Page
|
||||
setPage: (p: Page) => void
|
||||
children: React.ReactNode
|
||||
}): JSX.Element {
|
||||
const { profile, projects, currentProject, setCurrentProject, refreshProjects, signOut } = useApp()
|
||||
const [picker, setPicker] = useState(false)
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [theme, setThemeState] = useState<Theme>(getTheme())
|
||||
|
||||
const toggleTheme = (): void => {
|
||||
const next: Theme = theme === 'dark' ? 'light' : 'dark'
|
||||
setTheme(next)
|
||||
setThemeState(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Barre latérale */}
|
||||
<aside className="flex w-64 shrink-0 flex-col border-r border-border bg-sidebar">
|
||||
<div className="flex items-center gap-2.5 px-4 py-4">
|
||||
<Logo size={28} />
|
||||
<span className="font-semibold text-ink">GameDev Tracker</span>
|
||||
</div>
|
||||
|
||||
{/* Sélecteur de projet */}
|
||||
<div className="relative px-3">
|
||||
<button
|
||||
className="flex w-full items-center justify-between rounded-lg border border-border bg-panel2 px-3 py-2 text-left text-sm hover:border-accent"
|
||||
onClick={() => setPicker((v) => !v)}
|
||||
>
|
||||
<span className="truncate">
|
||||
<span className="block text-[10px] uppercase text-subtle">Projet</span>
|
||||
<span className="font-medium text-ink">{currentProject?.name ?? 'Aucun projet'}</span>
|
||||
</span>
|
||||
<ChevronDown size={16} className="text-muted" />
|
||||
</button>
|
||||
{picker && (
|
||||
<div className="absolute left-3 right-3 z-20 mt-1 rounded-lg border border-border bg-panel2 py-1 shadow-xl">
|
||||
{projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className="block w-full px-3 py-2 text-left text-sm hover:bg-border"
|
||||
onClick={() => {
|
||||
setCurrentProject(p)
|
||||
setPicker(false)
|
||||
}}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="flex w-full items-center gap-2 border-t border-border px-3 py-2 text-left text-sm text-accent2 hover:bg-border"
|
||||
onClick={() => {
|
||||
setShowNew(true)
|
||||
setPicker(false)
|
||||
}}
|
||||
>
|
||||
<Plus size={14} /> Nouveau projet
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="mt-4 flex-1 space-y-1 px-3">
|
||||
{NAV.filter((n) => !n.adminOnly || profile?.is_admin).map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => setPage(n.id)}
|
||||
className={`relative flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
page === n.id
|
||||
? 'text-accent'
|
||||
: 'text-muted hover:bg-accent/10 hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
{page === n.id && (
|
||||
<motion.span
|
||||
layoutId="nav-active"
|
||||
className="absolute inset-0 rounded-lg bg-accent/15 ring-1 ring-inset ring-accent/25"
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<n.icon size={18} className="relative z-10" />
|
||||
<span className="relative z-10">{n.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Pied : utilisateur */}
|
||||
<div className="border-t border-border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar profile={profile} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-ink">{profile?.full_name}</div>
|
||||
<div className="truncate text-xs text-subtle">
|
||||
{profile ? ROLE_LABELS[profile.role] : ''}
|
||||
</div>
|
||||
</div>
|
||||
<NotificationBell setPage={setPage} />
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
title={theme === 'dark' ? 'Passer en clair' : 'Passer en sombre'}
|
||||
className="text-muted hover:text-ink"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<button onClick={signOut} title="Se déconnecter" className="text-muted hover:text-rose-600">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Contenu */}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
{currentProject || page === 'admin' ? (
|
||||
children
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-muted">
|
||||
<Settings size={40} className="text-gray-600" />
|
||||
<p>Aucun projet pour le moment.</p>
|
||||
<button className="btn-primary" onClick={() => setShowNew(true)}>
|
||||
<Plus size={16} /> Créer le premier projet
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{showNew && <NewProjectModal onClose={() => setShowNew(false)} onCreated={refreshProjects} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewProjectModal({
|
||||
onClose,
|
||||
onCreated
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated: () => Promise<void>
|
||||
}): JSX.Element {
|
||||
const { profile, setCurrentProject } = useApp()
|
||||
const [name, setName] = useState('')
|
||||
const [desc, setDesc] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const create = async (): Promise<void> => {
|
||||
if (!name.trim()) return
|
||||
setBusy(true)
|
||||
const { data } = await supa()
|
||||
.from('projects')
|
||||
.insert({ name: name.trim(), description: desc.trim() || null, created_by: profile?.id })
|
||||
.select()
|
||||
.single()
|
||||
await onCreated()
|
||||
if (data) setCurrentProject(data)
|
||||
setBusy(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Nouveau projet" onClose={onClose}>
|
||||
<label className="label">Nom du jeu / projet</label>
|
||||
<input
|
||||
className="input mb-3"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Mon super jeu"
|
||||
/>
|
||||
<label className="label">Description (optionnel)</label>
|
||||
<textarea
|
||||
className="input mb-4 h-24 resize-none"
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
placeholder="Genre, plateforme, pitch..."
|
||||
/>
|
||||
<button className="btn-primary w-full" onClick={create} disabled={busy || !name.trim()}>
|
||||
Créer
|
||||
</button>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
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 }
|
||||
|
||||
Reference in New Issue
Block a user