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>
|
||||
);
|
||||
}
|
||||
+213
-81
@@ -7,27 +7,74 @@ import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Logomark } from "@/components/logomark";
|
||||
import { HexMark, HoneycombPattern } from "@/components/honeycomb";
|
||||
import { AdminCommandPalette, type PaletteEntry } from "@/components/admin-command-palette";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||
|
||||
function getAdminNav(t: Dictionary) {
|
||||
/** Was an einer Seite gerade auf Bearbeitung wartet (Zaehler in der Seitenleiste). */
|
||||
export type AdminBadges = Partial<Record<string, number>>;
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert statt einer langen Liste: 13 Eintraege lesen sich in vier
|
||||
* kurzen Bloecken schneller, und die taegliche Arbeit (Verkauf) steht oben.
|
||||
*/
|
||||
function getAdminNavGroups(t: Dictionary): { label: string | null; items: NavItem[] }[] {
|
||||
return [
|
||||
{ href: "/admin", label: t.adminShell.navDashboard },
|
||||
{ href: "/admin/bestellungen", label: t.adminShell.navOrders },
|
||||
{ href: "/admin/retouren", label: t.adminShell.navReturns },
|
||||
{ href: "/admin/produkte", label: t.adminShell.navProducts },
|
||||
{ href: "/admin/ernten", label: t.adminShell.navHarvests },
|
||||
{ href: "/admin/chargen", label: t.adminShell.navBatches },
|
||||
{ href: "/admin/anfragen", label: t.adminShell.navBusinessInquiries },
|
||||
{ href: "/admin/benutzer", label: t.adminShell.navUsers },
|
||||
{ href: "/admin/versand", label: t.adminShell.navShipping },
|
||||
{ href: "/admin/gutscheine", label: t.adminShell.navVouchers },
|
||||
{ href: "/admin/bewertungen", label: t.adminShell.navReviews },
|
||||
{ href: "/admin/inhalte", label: t.adminShell.navContent },
|
||||
{ href: "/admin/audit-log", label: t.adminShell.navAuditLog },
|
||||
{ label: null, items: [{ href: "/admin", label: t.adminShell.navDashboard }] },
|
||||
{
|
||||
label: t.adminShell.groupSales,
|
||||
items: [
|
||||
{ href: "/admin/bestellungen", label: t.adminShell.navOrders },
|
||||
{ href: "/admin/retouren", label: t.adminShell.navReturns },
|
||||
{ href: "/admin/gutscheine", label: t.adminShell.navVouchers },
|
||||
{ href: "/admin/versand", label: t.adminShell.navShipping },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t.adminShell.groupRange,
|
||||
items: [
|
||||
{ href: "/admin/produkte", label: t.adminShell.navProducts },
|
||||
{ href: "/admin/ernten", label: t.adminShell.navHarvests },
|
||||
{ href: "/admin/chargen", label: t.adminShell.navBatches },
|
||||
{ href: "/admin/bewertungen", label: t.adminShell.navReviews },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t.adminShell.groupCustomers,
|
||||
items: [
|
||||
{ href: "/admin/benutzer", label: t.adminShell.navUsers },
|
||||
{ href: "/admin/anfragen", label: t.adminShell.navBusinessInquiries },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t.adminShell.groupSite,
|
||||
items: [
|
||||
{ href: "/admin/inhalte", label: t.adminShell.navContent },
|
||||
{ href: "/admin/audit-log", label: t.adminShell.navAuditLog },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getPaletteEntries(t: Dictionary): PaletteEntry[] {
|
||||
const pages = getAdminNavGroups(t).flatMap((group) =>
|
||||
group.items.map((item) => ({ ...item, section: "pages" as const }))
|
||||
);
|
||||
const actions: PaletteEntry[] = [
|
||||
{ href: "/admin/produkte/neu", label: t.adminShell.actionNewProduct, section: "actions" },
|
||||
{ href: "/admin/gutscheine/neu", label: t.adminShell.actionNewVoucher, section: "actions" },
|
||||
{ href: "/admin/benutzer/neu", label: t.adminShell.actionNewUser, section: "actions" },
|
||||
{ href: "/admin/versand/neu", label: t.adminShell.actionNewShippingTier, section: "actions" },
|
||||
{ href: "/shop", label: t.adminShell.actionViewShop, section: "actions" },
|
||||
];
|
||||
return [...pages, ...actions];
|
||||
}
|
||||
|
||||
function isActiveHref(pathname: string, href: string): boolean {
|
||||
if (href === "/admin") return pathname === "/admin";
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
@@ -35,107 +82,186 @@ function isActiveHref(pathname: string, href: string): boolean {
|
||||
|
||||
function NavLinks({
|
||||
pathname,
|
||||
adminNav,
|
||||
groups,
|
||||
badges,
|
||||
onNavigate,
|
||||
waitingLabel,
|
||||
}: {
|
||||
pathname: string;
|
||||
adminNav: ReturnType<typeof getAdminNav>;
|
||||
groups: ReturnType<typeof getAdminNavGroups>;
|
||||
badges: AdminBadges;
|
||||
onNavigate?: () => void;
|
||||
waitingLabel: (count: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{adminNav.map((item) => {
|
||||
const active = isActiveHref(pathname, item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`group flex items-center gap-3 px-5 py-2.5 text-sm transition-colors ${
|
||||
active ? "bg-cream/10 text-cream" : "text-cream/70 hover:text-cream hover:bg-cream/5"
|
||||
}`}
|
||||
>
|
||||
{/* Wabe statt farbigem Seitenbalken markiert die aktive Seite. */}
|
||||
<HexMark
|
||||
filled={active}
|
||||
className={`h-2.5 w-2.5 flex-shrink-0 transition-[color,transform] duration-300 ${
|
||||
active ? "text-honey-light" : "text-cream/25 group-hover:text-cream/60 group-hover:rotate-90"
|
||||
}`}
|
||||
/>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div key={group.label ?? groupIndex} className={groupIndex > 0 ? "mt-5" : ""}>
|
||||
{group.label ? (
|
||||
<p className="px-5 pb-1.5 text-[10px] tracking-wide-caps text-cream/40">{group.label}</p>
|
||||
) : null}
|
||||
{group.items.map((item) => {
|
||||
const active = isActiveHref(pathname, item.href);
|
||||
const count = badges[item.href] ?? 0;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`group relative flex items-center gap-3 px-5 py-2 text-sm transition-colors ${
|
||||
active ? "text-cream" : "text-cream/70 hover:text-cream"
|
||||
}`}
|
||||
>
|
||||
{/* Aktive Seite als weich einblendende Flaeche, die beim
|
||||
Wechsel zur neuen Seite hinueberwandert. */}
|
||||
{active ? (
|
||||
<motion.span
|
||||
layoutId="admin-nav-active"
|
||||
className="absolute inset-y-0.5 left-2 right-2 -z-10 bg-cream/10"
|
||||
transition={{ type: "spring", stiffness: 420, damping: 36 }}
|
||||
/>
|
||||
) : null}
|
||||
<HexMark
|
||||
filled={active}
|
||||
className={`h-2.5 w-2.5 flex-shrink-0 transition-[color,transform] duration-300 ${
|
||||
active ? "text-honey-light" : "text-cream/25 group-hover:text-cream/60 group-hover:rotate-90"
|
||||
}`}
|
||||
/>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{count > 0 ? (
|
||||
<span
|
||||
className="min-w-5 rounded-full bg-honey-dark px-1.5 text-center text-[11px] leading-5 text-cream tabular-nums"
|
||||
title={waitingLabel(count)}
|
||||
>
|
||||
<span aria-hidden="true">{count}</span>
|
||||
<span className="sr-only">{waitingLabel(count)}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Wordmark() {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<Link href="/admin" className="flex items-center gap-2">
|
||||
<Logomark className="h-7 w-7 flex-shrink-0" variant="inverted" />
|
||||
<span className="tracking-[0.15em] text-xs whitespace-nowrap">WABENHAIN ADMIN</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteTrigger({ onClick, label, shortcut }: { onClick: () => void; label: string; shortcut: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="mx-3 flex w-[calc(100%-1.5rem)] items-center gap-2 border border-cream/15 px-3 py-2 text-left text-xs text-cream/60 transition-colors hover:border-cream/35 hover:text-cream"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" 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>
|
||||
<span className="flex-1">{label}</span>
|
||||
<kbd className="border border-cream/20 px-1 text-[10px] text-cream/50">{shortcut}</kbd>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdminShellProps {
|
||||
adminName: string;
|
||||
badges?: AdminBadges;
|
||||
logoutAction: () => void | Promise<void>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar-Layout fuer den Admin-Bereich statt der frueheren einzeiligen,
|
||||
* horizontal scrollenden Navigation (11 Punkte passten auf typischen
|
||||
* Bildschirmbreiten nicht nebeneinander). Ab lg persistente Sidebar links,
|
||||
* darunter ein Vollbild-Overlay-Menue nach demselben Muster wie die mobile
|
||||
* Hauptnavigation in site-chrome.tsx.
|
||||
* Eigener Arbeitsbereich fuer die Imkerei: gruppierte Seitenleiste mit
|
||||
* Zaehlern fuer alles, was gerade wartet, und einer Schnellsuche (Strg/Cmd+K).
|
||||
* Ab lg persistente Seitenleiste links, darunter ein Vollbild-Menue nach
|
||||
* demselben Muster wie die mobile Hauptnavigation in site-chrome.tsx. Die
|
||||
* Shop-Kopf- und -Fusszeile blendet das Root-Layout hier aus
|
||||
* (components/hide-on-admin.tsx).
|
||||
*/
|
||||
export function AdminShell({ adminName, logoutAction, children }: AdminShellProps) {
|
||||
export function AdminShell({ adminName, badges = {}, logoutAction, children }: AdminShellProps) {
|
||||
const { t } = useI18n();
|
||||
const adminNav = getAdminNav(t);
|
||||
const groups = getAdminNavGroups(t);
|
||||
const pathname = usePathname();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
||||
const paletteLabels = {
|
||||
placeholder: t.adminShell.palettePlaceholder,
|
||||
noResults: t.adminShell.paletteNoResults,
|
||||
pages: t.adminShell.paletteSectionPages,
|
||||
actions: t.adminShell.paletteSectionActions,
|
||||
dialog: t.adminShell.paletteOpen,
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<div className="px-5 py-4 border-t border-cream/10">
|
||||
<p className="text-xs text-cream/60 truncate">{t.adminShell.loggedInAs(adminName)}</p>
|
||||
<form action={logoutAction} className="mt-2">
|
||||
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
|
||||
{t.adminShell.logout}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-cream/40 lg:flex">
|
||||
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-60 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
|
||||
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-64 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
|
||||
<HoneycombPattern className="-z-10 text-honey-light/[0.06]" size={18} fade="bottom" />
|
||||
<div className="flex items-center h-16 px-5 border-b border-cream/10">
|
||||
<Wordmark />
|
||||
</div>
|
||||
<nav aria-label={t.adminShell.navAriaLabel} className="flex-1 overflow-y-auto py-4 flex flex-col gap-0.5">
|
||||
<NavLinks pathname={pathname} adminNav={adminNav} />
|
||||
</nav>
|
||||
<div className="px-5 py-4 border-t border-cream/10">
|
||||
<p className="text-xs text-cream/60 truncate">{t.adminShell.loggedInAs(adminName)}</p>
|
||||
<form action={logoutAction} className="mt-2">
|
||||
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
|
||||
{t.adminShell.logout}
|
||||
</button>
|
||||
</form>
|
||||
<div className="pt-4">
|
||||
<PaletteTrigger
|
||||
onClick={() => setPaletteOpen(true)}
|
||||
label={t.adminShell.paletteOpen}
|
||||
shortcut={t.adminShell.paletteShortcut}
|
||||
/>
|
||||
</div>
|
||||
<nav aria-label={t.adminShell.navAriaLabel} className="relative isolate flex-1 overflow-y-auto py-4">
|
||||
<NavLinks pathname={pathname} groups={groups} badges={badges} waitingLabel={t.adminShell.badgeWaiting} />
|
||||
</nav>
|
||||
{footer}
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<header className="lg:hidden sticky top-0 z-30 bg-forest text-cream flex items-center justify-between h-16 px-5">
|
||||
<Wordmark />
|
||||
<button
|
||||
aria-label={t.adminShell.openMenu}
|
||||
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
aria-label={t.adminShell.paletteOpen}
|
||||
className="w-10 h-10 flex items-center justify-center"
|
||||
onClick={() => setPaletteOpen(true)}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m16 16 4.5 4.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label={t.adminShell.openMenu}
|
||||
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-6xl mx-auto px-6 py-10 overflow-x-hidden">
|
||||
{children}
|
||||
</main>
|
||||
{/* Kein zweites <main>: das Root-Layout stellt bereits eines. */}
|
||||
<div className="admin-content max-w-6xl mx-auto px-6 py-10 overflow-x-hidden">{children}</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
@@ -159,20 +285,26 @@ export function AdminShell({ adminName, logoutAction, children }: AdminShellProp
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav aria-label={t.adminShell.mobileNavAriaLabel} className="flex-1 overflow-y-auto py-4">
|
||||
<NavLinks pathname={pathname} adminNav={adminNav} onNavigate={() => setMobileOpen(false)} />
|
||||
<nav aria-label={t.adminShell.mobileNavAriaLabel} className="relative isolate flex-1 overflow-y-auto py-4">
|
||||
<NavLinks
|
||||
pathname={pathname}
|
||||
groups={groups}
|
||||
badges={badges}
|
||||
waitingLabel={t.adminShell.badgeWaiting}
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
/>
|
||||
</nav>
|
||||
<div className="px-5 py-4 border-t border-cream/10 flex-shrink-0">
|
||||
<p className="text-xs text-cream/60">{t.adminShell.loggedInAs(adminName)}</p>
|
||||
<form action={logoutAction} className="mt-2">
|
||||
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
|
||||
{t.adminShell.logout}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{footer}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<AdminCommandPalette
|
||||
entries={getPaletteEntries(t)}
|
||||
labels={paletteLabels}
|
||||
open={paletteOpen}
|
||||
onOpenChange={setPaletteOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Blendet Shop-Kopf- und -Fusszeile im Admin-Bereich aus: dort arbeitet die
|
||||
* Imkerei in einem eigenen Arbeitsbereich mit Seitenleiste
|
||||
* (components/admin-shell.tsx), statt zwischen Shop-Navigation und
|
||||
* Admin-Menue zu stehen.
|
||||
*/
|
||||
export function HideOnAdmin({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
if (pathname === "/admin" || pathname.startsWith("/admin/")) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
Reference in new issue
Block a user