Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m26s
"Inhalte" becomes Admin -> Einstellungen, grouped into tabs so each area stands on its own: Firma & Versand, Marke, Startseite, Seiten, Social Media, Rechtstexte and System & Tests. Tabs are plain links (?reiter=...), bookmarkable and usable without JavaScript; saving returns to the same tab. System & Tests brings everything that checks the running shop into one place: - integration overview (Stripe, Stripe webhook, Microsoft 365 mail, Microsoft sign-in, Turnstile, Google reviews, DHL, ApiCore): only whether each is set up and the names of missing variables, never a value; warns when Stripe runs on test keys or mixes test/live - the email test, now with the IPv4 network checks for smtp.office365.com:587 and login.microsoftonline.com:443 /admin/inhalte and /admin/e-mail-test redirect to the new page; the separate "E-Mail-Test" menu entry is gone. The tab parameter and the saved group are validated against a fixed list before they are used in a redirect. Docs point to the new location and cover the ENETUNREACH (IPv6) case. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import type { ReactNode } from "react";
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
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";
|
|
import { ThemeToggle } from "@/components/theme-toggle";
|
|
import type { ThemeChoice } from "@/lib/theme";
|
|
|
|
/** 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 [
|
|
{ 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/widerrufe", label: t.adminShell.navWithdrawals },
|
|
{ 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/einstellungen", 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}/`);
|
|
}
|
|
|
|
function NavLinks({
|
|
pathname,
|
|
groups,
|
|
badges,
|
|
onNavigate,
|
|
waitingLabel,
|
|
}: {
|
|
pathname: string;
|
|
groups: ReturnType<typeof getAdminNavGroups>;
|
|
badges: AdminBadges;
|
|
onNavigate?: () => void;
|
|
waitingLabel: (count: number) => string;
|
|
}) {
|
|
return (
|
|
<>
|
|
{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-surface/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-deep 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({ logoSrc }: { logoSrc: string | null }) {
|
|
return (
|
|
<Link href="/admin" className="flex items-center gap-2">
|
|
<Logomark className="h-7 w-7 flex-shrink-0" variant="inverted" src={logoSrc} />
|
|
<span className="tracking-[0.15em] text-xs whitespace-nowrap">WABENHAIN ADMIN</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;
|
|
/** Gewaehlte Darstellung (Cookie), siehe lib/theme.ts. */
|
|
themeChoice?: ThemeChoice;
|
|
/** Logo fuer dunkle Flaechen, siehe lib/brand.ts. */
|
|
logoSrc?: string | null;
|
|
badges?: AdminBadges;
|
|
logoutAction: () => void | Promise<void>;
|
|
children: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
logoSrc = null,
|
|
themeChoice = "system",
|
|
badges = {},
|
|
logoutAction,
|
|
children,
|
|
}: AdminShellProps) {
|
|
const { t } = useI18n();
|
|
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">
|
|
{/* Der Shop-Kopf ist im Admin ausgeblendet (HideOnAdmin) — ohne diesen
|
|
Link fuehrte kein Weg zurueck auf die Website. */}
|
|
<div className="mb-4 flex items-center justify-between gap-3">
|
|
<Link
|
|
href="/"
|
|
className="group inline-flex items-center gap-2 py-1 text-sm text-cream/90 hover:text-cream"
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.75"
|
|
aria-hidden="true"
|
|
className="transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:-translate-x-0.5 motion-reduce:transition-none"
|
|
>
|
|
<path d="M15 6l-6 6 6 6" />
|
|
</svg>
|
|
<span className="link-draw">{t.adminShell.backToWebsite}</span>
|
|
</Link>
|
|
<ThemeToggle initial={themeChoice} className="-mr-2 text-cream/70 hover:text-cream" />
|
|
</div>
|
|
<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-surface/40 lg:flex">
|
|
<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 logoSrc={logoSrc} />
|
|
</div>
|
|
<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 logoSrc={logoSrc} />
|
|
<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>
|
|
|
|
{/* 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>
|
|
{mobileOpen ? (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="lg:hidden fixed inset-0 z-50 bg-forest text-cream flex flex-col"
|
|
>
|
|
<div className="flex items-center justify-between h-16 px-5 border-b border-cream/10 flex-shrink-0">
|
|
<Wordmark logoSrc={logoSrc} />
|
|
<button
|
|
aria-label={t.adminShell.closeMenu}
|
|
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
|
|
onClick={() => setMobileOpen(false)}
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
<path d="M6 6l12 12M18 6L6 18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<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>
|
|
{footer}
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
|
|
<AdminCommandPalette
|
|
entries={getPaletteEntries(t)}
|
|
labels={paletteLabels}
|
|
open={paletteOpen}
|
|
onOpenChange={setPaletteOpen}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|