Layout admin : - src/app/admin/layout.tsx : route protégée requireRole(ADMIN), sidebar + topbar + breadcrumbs, data-admin sur racine pour theme sobre indépendant du theme public - Sidebar : 12 sections groupées (Vue d'ensemble, Catalogue, Activité, Membres, Contenu, Système), highlight de la route courante - TopBar : prompt ⌘K, lien vers site public, email admin - Breadcrumbs : auto depuis pathname - CommandPalette : ⌘K / Ctrl K, navigation ↑↓ + Entrée, recherche live debounced 150ms Dashboard : - 7 KPI cards avec tone neutral/ok/warn/info (réservations semaine, confirmées 30j, revenus reversés, occupation, nouveaux users, carbets publiés, avis à modérer) - Section raccourcis fréquents Theme admin : - globals.css : [data-admin] override le background+font, neutralise les borders sépia/papier teinté du theme aquarelle, garantit lisibilité permanente Recherche globale : - lib/admin/search.ts : query parallèle sur Carbet, User, Booking, ContentPage, PirogueProvider (5 résultats par catégorie, LIKE insensitive) - api/admin/search?q=… route handler avec requireRole KPI : - lib/admin/kpis.ts : 7 métriques live (cache 0), Promise.all, helper formatEur Pas de dépendance externe ajoutée (cmdk, shadcn) — composants custom Tailwind pour rester léger.
177 lines
5.8 KiB
TypeScript
177 lines
5.8 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { SearchHit } from "@/lib/admin/search";
|
|
|
|
const TYPE_LABEL: Record<string, string> = {
|
|
carbet: "Carbet",
|
|
user: "Utilisateur",
|
|
booking: "Réservation",
|
|
page: "Page",
|
|
provider: "Prestataire",
|
|
};
|
|
const TYPE_ACCENT: Record<string, string> = {
|
|
carbet: "bg-emerald-100 text-emerald-800",
|
|
user: "bg-sky-100 text-sky-800",
|
|
booking: "bg-amber-100 text-amber-800",
|
|
page: "bg-violet-100 text-violet-800",
|
|
provider: "bg-rose-100 text-rose-800",
|
|
};
|
|
|
|
/**
|
|
* Palette ⌘K minimaliste, sans dépendance externe. Server search via
|
|
* /api/admin/search?q=…, navigation au clavier (↑/↓/Enter/Esc).
|
|
*/
|
|
export function CommandPalette() {
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const [hits, setHits] = useState<SearchHit[]>([]);
|
|
const [selected, setSelected] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
|
|
// Ouvre la palette sur ⌘K / Ctrl+K. Esc ferme.
|
|
useEffect(() => {
|
|
function onKey(e: KeyboardEvent) {
|
|
const cmd = e.metaKey || e.ctrlKey;
|
|
if (cmd && e.key.toLowerCase() === "k") {
|
|
e.preventDefault();
|
|
setOpen((v) => !v);
|
|
} else if (e.key === "Escape") {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setQuery("");
|
|
setHits([]);
|
|
setSelected(0);
|
|
setTimeout(() => inputRef.current?.focus(), 50);
|
|
}
|
|
}, [open]);
|
|
|
|
const runSearch = useCallback(async (q: string) => {
|
|
if (q.trim().length < 2) {
|
|
setHits([]);
|
|
return;
|
|
}
|
|
abortRef.current?.abort();
|
|
const ac = new AbortController();
|
|
abortRef.current = ac;
|
|
setLoading(true);
|
|
try {
|
|
const r = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, { signal: ac.signal });
|
|
if (r.ok) {
|
|
const j = await r.json();
|
|
setHits(j.hits ?? []);
|
|
setSelected(0);
|
|
}
|
|
} catch {
|
|
// aborted ou erreur silencieuse
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const id = setTimeout(() => runSearch(query), 150);
|
|
return () => clearTimeout(id);
|
|
}, [query, runSearch]);
|
|
|
|
function onListKey(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setSelected((s) => Math.min(s + 1, hits.length - 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setSelected((s) => Math.max(s - 1, 0));
|
|
} else if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
const hit = hits[selected];
|
|
if (hit) {
|
|
setOpen(false);
|
|
router.push(hit.href);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-start justify-center bg-zinc-900/30 px-4 pt-[12vh] backdrop-blur-sm"
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
<div
|
|
className="w-full max-w-xl overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center gap-2 border-b border-zinc-200 px-3 py-2">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-zinc-400">
|
|
<circle cx="11" cy="11" r="7" />
|
|
<path d="M21 21 L17 17" />
|
|
</svg>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
placeholder="Rechercher un carbet, un user, une résa…"
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={onListKey}
|
|
className="flex-1 bg-transparent text-sm text-zinc-900 placeholder-zinc-400 focus:outline-none"
|
|
/>
|
|
<kbd className="rounded border border-zinc-300 bg-zinc-100 px-1.5 py-0.5 text-[10px] text-zinc-500">
|
|
ESC
|
|
</kbd>
|
|
</div>
|
|
|
|
<div className="max-h-96 overflow-y-auto py-1">
|
|
{loading ? (
|
|
<div className="px-4 py-3 text-sm text-zinc-500">…</div>
|
|
) : query.length >= 2 && hits.length === 0 ? (
|
|
<div className="px-4 py-3 text-sm text-zinc-500">Aucun résultat.</div>
|
|
) : hits.length === 0 ? (
|
|
<div className="px-4 py-3 text-sm text-zinc-500">
|
|
Tape au moins 2 caractères. Navigation : ↑ ↓ / Entrée.
|
|
</div>
|
|
) : (
|
|
<ul>
|
|
{hits.map((h, i) => (
|
|
<li key={`${h.type}-${h.id}`}>
|
|
<Link
|
|
href={h.href}
|
|
onClick={() => setOpen(false)}
|
|
onMouseEnter={() => setSelected(i)}
|
|
className={`flex items-center justify-between gap-3 px-3 py-2 text-sm ${
|
|
i === selected ? "bg-zinc-100" : "hover:bg-zinc-50"
|
|
}`}
|
|
>
|
|
<span className="flex items-center gap-2 truncate">
|
|
<span
|
|
className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${TYPE_ACCENT[h.type]}`}
|
|
>
|
|
{TYPE_LABEL[h.type]}
|
|
</span>
|
|
<span className="truncate text-zinc-900">{h.title}</span>
|
|
</span>
|
|
{h.subtitle ? (
|
|
<span className="truncate text-xs text-zinc-500">{h.subtitle}</span>
|
|
) : null}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|