feat(admin): shell admin + dashboard KPI + recherche ⌘K (Sprint 1)
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.
This commit is contained in:
parent
ffb39a3bf5
commit
bcb93c6b29
11 changed files with 873 additions and 9 deletions
46
src/components/admin/Breadcrumbs.tsx
Normal file
46
src/components/admin/Breadcrumbs.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
admin: "Admin",
|
||||
carbets: "Carbets",
|
||||
bookings: "Réservations",
|
||||
reviews: "Avis",
|
||||
users: "Utilisateurs",
|
||||
organizations: "Organisations",
|
||||
"pirogue-providers": "Prestataires",
|
||||
media: "Médias",
|
||||
"content-pages": "Pages",
|
||||
plugins: "Plugins",
|
||||
settings: "Paramètres",
|
||||
audit: "Audit log",
|
||||
};
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const pathname = usePathname();
|
||||
if (!pathname.startsWith("/admin")) return null;
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
// skip if just /admin
|
||||
if (parts.length <= 1) return null;
|
||||
return (
|
||||
<nav className="flex items-center gap-1 px-4 pt-3 text-xs text-zinc-500">
|
||||
{parts.map((p, i) => {
|
||||
const href = "/" + parts.slice(0, i + 1).join("/");
|
||||
const isLast = i === parts.length - 1;
|
||||
const label = LABELS[p] ?? decodeURIComponent(p);
|
||||
return (
|
||||
<span key={href} className="flex items-center gap-1">
|
||||
{i > 0 ? <span className="text-zinc-300">/</span> : null}
|
||||
{isLast ? (
|
||||
<span className="text-zinc-700">{label}</span>
|
||||
) : (
|
||||
<Link href={href} className="hover:text-zinc-900">{label}</Link>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
177
src/components/admin/CommandPalette.tsx
Normal file
177
src/components/admin/CommandPalette.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
44
src/components/admin/KPICard.tsx
Normal file
44
src/components/admin/KPICard.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
type Tone = "neutral" | "ok" | "warn" | "info";
|
||||
|
||||
const toneStyles: Record<Tone, string> = {
|
||||
neutral: "border-zinc-200 bg-white",
|
||||
ok: "border-emerald-200 bg-emerald-50",
|
||||
warn: "border-amber-200 bg-amber-50",
|
||||
info: "border-sky-200 bg-sky-50",
|
||||
};
|
||||
|
||||
const toneText: Record<Tone, string> = {
|
||||
neutral: "text-zinc-900",
|
||||
ok: "text-emerald-900",
|
||||
warn: "text-amber-900",
|
||||
info: "text-sky-900",
|
||||
};
|
||||
|
||||
export function KPICard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone = "neutral",
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
hint?: string;
|
||||
tone?: Tone;
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-lg border ${toneStyles[tone]} p-5 shadow-sm`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-zinc-500">{label}</span>
|
||||
{icon ? <span className="text-zinc-400">{icon}</span> : null}
|
||||
</div>
|
||||
<div className={`mt-2 font-mono text-3xl font-semibold tracking-tight ${toneText[tone]}`}>
|
||||
{value}
|
||||
</div>
|
||||
{hint ? <div className="mt-1 text-xs text-zinc-500">{hint}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
src/components/admin/Sidebar.tsx
Normal file
198
src/components/admin/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
badge?: number;
|
||||
};
|
||||
|
||||
type NavGroup = {
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
};
|
||||
|
||||
const ICONS = {
|
||||
dashboard: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
),
|
||||
carbets: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 21 L12 6 L21 21 Z" />
|
||||
<path d="M9 21 V13 H15 V21" />
|
||||
</svg>
|
||||
),
|
||||
bookings: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M3 10 H21" />
|
||||
<path d="M8 3 V7" />
|
||||
<path d="M16 3 V7" />
|
||||
</svg>
|
||||
),
|
||||
users: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="9" cy="8" r="3" />
|
||||
<path d="M3 21 Q3 14 9 14 Q15 14 15 21" />
|
||||
<circle cx="17" cy="9" r="2.5" />
|
||||
<path d="M14 16 Q17 13 21 16" />
|
||||
</svg>
|
||||
),
|
||||
organizations: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="4" y="6" width="16" height="14" rx="1" />
|
||||
<path d="M4 10 H20" />
|
||||
<path d="M9 10 V20" />
|
||||
<path d="M15 10 V20" />
|
||||
</svg>
|
||||
),
|
||||
pirogue: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M3 14 Q12 22 21 14" />
|
||||
<path d="M5 12 H19" />
|
||||
<path d="M9 12 V8" />
|
||||
<path d="M15 12 V8" />
|
||||
</svg>
|
||||
),
|
||||
reviews: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2 L14.5 8.5 L21 9 L16 14 L17.5 21 L12 17.5 L6.5 21 L8 14 L3 9 L9.5 8.5 Z" />
|
||||
</svg>
|
||||
),
|
||||
media: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="M3 17 L9 11 L21 19" />
|
||||
</svg>
|
||||
),
|
||||
pages: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M5 3 H15 L19 7 V21 H5 Z" />
|
||||
<path d="M14 3 V8 H19" />
|
||||
<path d="M9 13 H15" />
|
||||
<path d="M9 17 H13" />
|
||||
</svg>
|
||||
),
|
||||
plugins: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<path d="M17 14 V21 M14 17 H20" />
|
||||
</svg>
|
||||
),
|
||||
settings: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1 V5 M12 19 V23 M4.2 4.2 L7 7 M17 17 L19.8 19.8 M1 12 H5 M19 12 H23 M4.2 19.8 L7 17 M17 7 L19.8 4.2" />
|
||||
</svg>
|
||||
),
|
||||
audit: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M4 4 H20 V20 H4 Z" />
|
||||
<path d="M8 9 H16 M8 13 H16 M8 17 H12" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: "Vue d'ensemble",
|
||||
items: [{ href: "/admin", label: "Dashboard", icon: ICONS.dashboard }],
|
||||
},
|
||||
{
|
||||
label: "Catalogue",
|
||||
items: [
|
||||
{ href: "/admin/carbets", label: "Carbets", icon: ICONS.carbets },
|
||||
{ href: "/admin/pirogue-providers", label: "Prestataires pirogue", icon: ICONS.pirogue },
|
||||
{ href: "/admin/media", label: "Médias", icon: ICONS.media },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Activité",
|
||||
items: [
|
||||
{ href: "/admin/bookings", label: "Réservations", icon: ICONS.bookings },
|
||||
{ href: "/admin/reviews", label: "Avis & modération", icon: ICONS.reviews },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Membres",
|
||||
items: [
|
||||
{ href: "/admin/users", label: "Utilisateurs", icon: ICONS.users },
|
||||
{ href: "/admin/organizations", label: "Organisations CE", icon: ICONS.organizations },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Contenu",
|
||||
items: [{ href: "/admin/content-pages", label: "Pages éditoriales", icon: ICONS.pages }],
|
||||
},
|
||||
{
|
||||
label: "Système",
|
||||
items: [
|
||||
{ href: "/admin/plugins", label: "Plugins", icon: ICONS.plugins },
|
||||
{ href: "/admin/settings", label: "Paramètres", icon: ICONS.settings },
|
||||
{ href: "/admin/audit", label: "Audit log", icon: ICONS.audit },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="flex h-full w-60 shrink-0 flex-col gap-1 border-r border-zinc-200 bg-[#f7f5f0] py-4">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="mx-3 mb-3 flex items-center gap-2 rounded px-2 py-1.5 text-sm font-semibold text-zinc-900"
|
||||
>
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-full bg-[#8c3d18]" />
|
||||
Karbé · Admin
|
||||
</Link>
|
||||
|
||||
{GROUPS.map((group) => (
|
||||
<section key={group.label} className="mb-2 px-3">
|
||||
<h3 className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{group.label}
|
||||
</h3>
|
||||
<ul className="space-y-0.5">
|
||||
{group.items.map((item) => {
|
||||
const active = pathname === item.href || (item.href !== "/admin" && pathname.startsWith(item.href));
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`flex items-center justify-between rounded px-2 py-1.5 text-sm transition ${
|
||||
active
|
||||
? "bg-white text-zinc-900 shadow-sm ring-1 ring-zinc-200"
|
||||
: "text-zinc-600 hover:bg-white/60 hover:text-zinc-900"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={active ? "text-[#8c3d18]" : "text-zinc-400"}>{item.icon}</span>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.badge !== undefined ? (
|
||||
<span className="rounded-full bg-zinc-200 px-1.5 py-0.5 text-[10px] font-medium text-zinc-700">
|
||||
{item.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
46
src/components/admin/TopBar.tsx
Normal file
46
src/components/admin/TopBar.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function TopBar({ userEmail }: { userEmail: string }) {
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
useEffect(() => {
|
||||
setIsMac(navigator.userAgent.includes("Mac"));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-zinc-200 bg-white px-4">
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||
<span>Cmd K pour rechercher</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const ev = new KeyboardEvent("keydown", {
|
||||
key: "k",
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
bubbles: true,
|
||||
});
|
||||
window.dispatchEvent(ev);
|
||||
}}
|
||||
className="hidden items-center gap-2 rounded border border-zinc-200 bg-zinc-50 px-2.5 py-1 text-xs text-zinc-600 hover:border-zinc-300 hover:bg-white sm:inline-flex"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M21 21 L17 17" />
|
||||
</svg>
|
||||
Rechercher…
|
||||
<kbd className="rounded bg-zinc-200 px-1 text-[10px] text-zinc-600">
|
||||
{isMac ? "⌘K" : "Ctrl K"}
|
||||
</kbd>
|
||||
</button>
|
||||
<a href="/" className="text-xs text-zinc-500 hover:text-zinc-900" target="_blank" rel="noreferrer">
|
||||
↗ Voir le site
|
||||
</a>
|
||||
<span className="text-xs text-zinc-500">{userEmail}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue