feat(admin): eigener Arbeitsbereich mit Dashboard, Zaehlern und Schnellsuche
- Admin ohne Shop-Kopf-/Fusszeile (HideOnAdmin); kein doppeltes <main> mehr - Seitenleiste gruppiert (Verkauf, Sortiment, Kundschaft, Seite), aktive Seite als wandernde Flaeche mit Wabe, Zaehler fuer Wartendes: bezahlte offene Bestellungen, neue Retouren, unfreigegebene Bewertungen, neue Firmenanfragen (COUNT-Abfragen erst nach Admin-/2FA-Pruefung) - Schnellsuche (Strg/Cmd+K): Seiten und haeufige Aktionen per Tastatur, tolerante Wortsuche, Ziele nur aus fester Liste - Dashboard: Begruessung nach Tageszeit (Berliner Zeit), eine Leitzahl "Umsatz heute" mit Vergleich zu gestern und Mini-Verlauf, "Zu erledigen" mit direkten Links bzw. "Alles erledigt"-Siegel, ruhigere Kennzahl-Kacheln - Diagramme nach dataviz-Skill: per Validator gepruefter Diagrammton #a0722f (>=3:1; #b88a44 erreichte nur 2,93:1), gerundete Balkenkappen, 2px Luecke, Tooltip je Balken (auch per Tastatur), Meter mit Spur im selben Ton, Rang der Top-Produkte als Wabe - Einheitliche Admin-Details zentral: groessere Ueberschriften, Tabellenkopf als Etikett, feste Ziffernbreite, eigene Auswahlpfeile Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
15ddceaa39
commit
c95c713717
11 files changed
+762
-133
No files matched your search
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { HexMark } from "@/components/honeycomb";
|
||||
|
||||
export interface PaletteEntry {
|
||||
href: string;
|
||||
label: string;
|
||||
section: "pages" | "actions";
|
||||
}
|
||||
|
||||
/**
|
||||
* Schnellsuche fuer den Admin-Bereich (Strg/Cmd + K): jede Seite und die
|
||||
* haeufigsten Aktionen sind zwei Tastendruecke entfernt, statt durch die
|
||||
* Seitenleiste zu scrollen. Pfeiltasten waehlen, Enter springt, Esc schliesst.
|
||||
*
|
||||
* Sucht tolerant: jedes getippte Wort muss irgendwo im Eintrag vorkommen
|
||||
* ("neu gut" findet "Neuen Gutschein anlegen"). Ziele sind ausschliesslich
|
||||
* die fest hinterlegten Eintraege, nie die Eingabe selbst.
|
||||
*/
|
||||
export function AdminCommandPalette({
|
||||
entries,
|
||||
labels,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
entries: PaletteEntry[];
|
||||
labels: { placeholder: string; noResults: string; pages: string; actions: string; dialog: string };
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Globale Tastenkombination — auch wenn der Fokus gerade woanders liegt.
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
onOpenChange(!open);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return entries;
|
||||
return entries.filter((entry) => words.every((word) => entry.label.toLowerCase().includes(word)));
|
||||
}, [entries, query]);
|
||||
|
||||
function close() {
|
||||
onOpenChange(false);
|
||||
setQuery("");
|
||||
setActive(0);
|
||||
}
|
||||
|
||||
function go(entry: PaletteEntry | undefined) {
|
||||
if (!entry) return;
|
||||
close();
|
||||
router.push(entry.href);
|
||||
}
|
||||
|
||||
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "Escape") {
|
||||
close();
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActive((i) => Math.min(results.length - 1, i + 1));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActive((i) => Math.max(0, i - 1));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
go(results[active]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[65] flex items-start justify-center bg-forest/40 px-4 pt-[15vh] backdrop-blur-[2px]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) close();
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={labels.dialog}
|
||||
initial={{ opacity: 0, y: -12, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.98, transition: { duration: 0.12 } }}
|
||||
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
|
||||
onAnimationComplete={() => inputRef.current?.focus()}
|
||||
className="w-full max-w-lg overflow-hidden border border-ink/10 bg-background shadow-[0_40px_80px_-30px_rgba(36,53,43,0.6)]"
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-ink/10 px-5">
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4 flex-shrink-0 text-ink/45" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={labels.placeholder}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="admin-palette-results"
|
||||
aria-activedescendant={results[active] ? `palette-${active}` : undefined}
|
||||
className="h-14 w-full bg-transparent text-base focus:outline-none focus-visible:outline-none focus:shadow-none"
|
||||
/>
|
||||
<kbd className="flex-shrink-0 border border-ink/15 px-1.5 py-0.5 text-[10px] text-ink/50">Esc</kbd>
|
||||
</div>
|
||||
<ul id="admin-palette-results" role="listbox" className="max-h-80 overflow-y-auto py-2">
|
||||
{results.length === 0 ? (
|
||||
<li className="px-5 py-6 text-center text-sm text-ink/60">{labels.noResults}</li>
|
||||
) : null}
|
||||
{results.map((entry, i) => {
|
||||
const showSection = i === 0 || results[i - 1].section !== entry.section;
|
||||
return (
|
||||
<li key={entry.href + entry.label} role="presentation">
|
||||
{showSection ? (
|
||||
<p className="px-5 pb-1 pt-3 text-[10px] tracking-wide-caps text-ink/45">
|
||||
{entry.section === "pages" ? labels.pages : labels.actions}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
id={`palette-${i}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => go(entry)}
|
||||
className={`flex w-full items-center gap-3 px-5 py-2.5 text-left text-sm transition-colors ${
|
||||
i === active ? "bg-honey/10 text-forest" : "text-ink/75"
|
||||
}`}
|
||||
>
|
||||
<HexMark
|
||||
filled={i === active}
|
||||
className={`h-2.5 w-2.5 flex-shrink-0 ${i === active ? "text-honey-dark" : "text-ink/25"}`}
|
||||
/>
|
||||
<span className="flex-1">{entry.label}</span>
|
||||
{i === active ? (
|
||||
<kbd className="border border-ink/15 px-1.5 py-0.5 text-[10px] text-ink/50">Enter</kbd>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user