Rollen-Farben statt reiner Markenfarben, damit hell und dunkel aus einem Satz Klassen entstehen: - heading (Ueberschriften, Links, Rahmen): hell Waldgruen, dunkel Creme - surface (abgesetzte Flaeche): hell Creme, dunkel angehobenes Dunkelgruen - forest bleibt die dunkle Markenflaeche (Hero, Baender, Knoepfe, Admin-Leiste), cream der helle Text darauf - honey-deep als fester Honigton fuer Flaechen; honey-dark (Text) wird im Dunkelmodus heller und bleibt lesbar Codemod ueber src: text-/border-forest -> *-heading, bg-cream -> bg-surface, bg-honey-dark -> bg-honey-deep. Im hellen Modus haben die Rollen exakt die bisherigen Werte - das Erscheinungsbild bleibt gleich. Umschalter (components/theme-toggle.tsx) Automatisch -> Hell -> Dunkel in Kopfzeile, Mobilmenue und Admin-Leiste. Die Wahl liegt im Cookie "theme" (nur light/dark, geprueft in lib/theme.ts); der Server setzt data-theme schon beim Ausliefern, nichts blitzt hell auf. "Automatisch" folgt der Systemeinstellung. Gewaehlter und automatischer Dunkelblock in globals.css werden per Test auf Gleichheit geprueft. Logo, Biene und Select-Pfeil nutzen die Rollenfarben. In der Kopfzeile steht "Honig entdecken" zwischen 1280 und 1535 px nicht mehr doppelt neben "Shop" (sonst stiess die Navigation an), der Slogan erscheint erst ab 1680 px. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
173 lines
6.7 KiB
TypeScript
173 lines
6.7 KiB
TypeScript
"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-heading" : "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>
|
|
);
|
|
}
|