feat: redesign CMS and unify German admin UI

This commit is contained in:
2026-09-17 16:51:24 +02:00
parent a2b3e99174
commit 7ec0c51ff5
129 changed files with 952 additions and 550 deletions
+2
View File
@@ -24,6 +24,7 @@ import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0
import { CalendarNavLink as CalendarNavLink_4d06fac03127b339306d5f5ecb073251 } from '../../../components/admin/CalendarNavLink'
import { AnalyticsNavLink as AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962 } from '../../../components/admin/AnalyticsNavLink'
import { ConversationsNavLink as ConversationsNavLink_67a2594cf28806bc76971e67212a6181 } from '../../../components/admin/ConversationsNavLink'
import { AdminWelcome as AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6 } from '../../../components/admin/AdminWelcome'
import { AdminCalendarView as AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a } from '../../../components/admin/AdminCalendarView'
import { AdminAnalyticsView as AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0 } from '../../../components/admin/AdminAnalyticsView'
import { AdminConversationsView as AdminConversationsView_180f5c400a066a0a8f248da44d961055 } from '../../../components/admin/AdminConversationsView'
@@ -57,6 +58,7 @@ export const importMap = {
"/components/admin/CalendarNavLink#CalendarNavLink": CalendarNavLink_4d06fac03127b339306d5f5ecb073251,
"/components/admin/AnalyticsNavLink#AnalyticsNavLink": AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962,
"/components/admin/ConversationsNavLink#ConversationsNavLink": ConversationsNavLink_67a2594cf28806bc76971e67212a6181,
"/components/admin/AdminWelcome#AdminWelcome": AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6,
"/components/admin/AdminCalendarView#AdminCalendarView": AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a,
"/components/admin/AdminAnalyticsView#AdminAnalyticsView": AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0,
"/components/admin/AdminConversationsView#AdminConversationsView": AdminConversationsView_180f5c400a066a0a8f248da44d961055,
@@ -6,10 +6,10 @@ import { calendarFeedUrl, generateCalendarFeedToken, hashCalendarFeedToken } fro
export const dynamic = "force-dynamic";
/**
* POST /api/admin/calendar-feed/regenerate — (re)generates Anna's personal
* POST /api/admin/calendar-feed/regenerate - (re)generates Anna's personal
* ICS feed link. Only the new token's hash is stored; the plaintext URL is
* returned exactly once in this response and can never be retrieved again
* — generating a new one immediately invalidates whichever link was issued
* - generating a new one immediately invalidates whichever link was issued
* before (see collections/Users.ts).
*/
export async function POST(request: Request) {
+2 -2
View File
@@ -23,7 +23,7 @@ function normalizeEventName(value: unknown): string | null {
return trimmed;
}
/** Hostname only (never path/query) — see collections/AnalyticsEvents.ts. */
/** Hostname only (never path/query) - see collections/AnalyticsEvents.ts. */
function referrerHostFrom(referrer: unknown): string | undefined {
if (typeof referrer !== "string" || !referrer) return undefined;
try {
@@ -35,7 +35,7 @@ function referrerHostFrom(referrer: unknown): string | undefined {
/**
* Public tracking beacon, called by lib/analytics/client.ts only after the
* visitor has granted statistics consent — but the client's own claim is
* visitor has granted statistics consent - but the client's own claim is
* never trusted alone: this route re-checks the CookieSettings global's
* `statistics.enabled` flag server-side before writing anything, so a
* global kill-switch (or a stale/tampered client) can never bypass it.
@@ -11,7 +11,7 @@ import type { Customer } from "@/payload-types";
export const dynamic = "force-dynamic";
// Deliberately identical whether or not an account exists / is already
// verified — this endpoint must never let a caller find out either of
// verified - this endpoint must never let a caller find out either of
// those things about an email address that isn't their own session.
const GENERIC_RESPONSE = {
ok: true,
@@ -60,7 +60,7 @@ export async function POST(request: Request) {
body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
// no body — handled below as a missing email
// no body - handled below as a missing email
}
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
if (!email || email.length > 254) {
@@ -38,7 +38,7 @@ export async function POST(request: Request, { params }: Args) {
}
// The beforeChange hook on booking-requests re-checks for conflicts
// (with an advisory lock) before allowing this to become "confirmed" —
// (with an advisory lock) before allowing this to become "confirmed" -
// the alternative slot may have been taken by someone else since it was
// proposed.
await payload.update({
@@ -16,7 +16,7 @@ export async function POST(request: Request, { params }: Args) {
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
}
// Cancelling is deliberately allowed regardless of verification status —
// Cancelling is deliberately allowed regardless of verification status -
// only creating/confirming a booking is a gated "protected" action.
return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
+1 -1
View File
@@ -67,7 +67,7 @@ export async function POST(request: Request) {
const end = new Date(start.getTime() + offer.durationMinutes * 60_000);
// Re-validate the slot is actually free right now (defense against a
// stale slot list) — the hard, race-safe check still runs again when
// stale slot list) - the hard, race-safe check still runs again when
// Anna confirms (see collections/BookingRequests.ts).
const dayBounds = berlinDayBounds(start);
return await withBookingTransaction(payload, dayBounds.key, async (req) => {
@@ -16,7 +16,7 @@ function safeEqual(a: string, b: string): boolean {
/**
* Intended to be called by an external cron job once daily, e.g.:
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/analytics-cleanup
* (a second, separate cron entry from /api/cron/event-reminders — reuses
* (a second, separate cron entry from /api/cron/event-reminders - reuses
* the same CRON_SECRET, no new secret needed)
*/
export async function GET(request: Request) {
+1 -1
View File
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
/**
* Used by the Docker healthcheck (see docker-compose.yml) and by
* setup.sh/deploy.sh to confirm the app can actually reach Postgres before
* being considered "up" — not just that the Node process is listening.
* being considered "up" - not just that the Node process is listening.
*/
export async function GET() {
try {
@@ -57,7 +57,7 @@ export async function POST(request: Request, { params }: Args) {
}
const { user } = await payload.auth({ headers: request.headers });
// Must check the collection, not just presence — a logged-in customer
// Must check the collection, not just presence - a logged-in customer
// (collection "customers") must never be treated as host.
const isHost = user?.collection === "users" && user.role === "admin";
@@ -81,7 +81,7 @@ export async function POST(request: Request, { params }: Args) {
role: isHost ? "host" : "participant",
accessVersion: access.accessVersion,
ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
// Only meaningful for participants — a host authenticates via a real
// Only meaningful for participants - a host authenticates via a real
// session already, so there's no need to anchor a kick to it (and
// hosts can't be kicked in the first place.
clientId: isHost ? undefined : clientId,
+383 -3
View File
@@ -1,3 +1,383 @@
/* Optional custom overrides for the Payload admin UI. Left empty intentionally —
the admin panel uses Payload's own default styling, only the public site
uses the ANOUMA design system (see app/(frontend)/globals.css). */
@font-face {
font-family: "Anouma Admin";
src: url("/fonts/baar-antropos.ttf") format("truetype");
font-display: swap;
font-style: normal;
font-weight: 100 900;
}
:root {
--anouma-ink: #342c31;
--anouma-plum: #66505f;
--anouma-mauve: #8b616d;
--anouma-rose: #d19ca1;
--anouma-cream: #f7f2ed;
--anouma-paper: #fffdfb;
--anouma-line: #e4d9d2;
--font-body: "Anouma Admin", Inter, ui-sans-serif, system-ui, sans-serif;
--font-mono: "SFMono-Regular", Consolas, monospace;
}
html,
body {
background: var(--anouma-cream);
color: var(--anouma-ink);
font-family: var(--font-body);
}
body,
button,
input,
textarea,
select {
font-family: var(--font-body);
}
/* The left navigation is intentionally dark and quiet. Content stays light. */
.nav {
background: var(--anouma-ink);
border-right: 1px solid #4b3f46;
color: #f8f2ed;
}
.nav__header {
border-bottom: 1px solid #4b3f46;
padding: 24px 20px 20px;
}
.nav__header a,
.nav__header button {
color: #fffaf6;
}
.nav__links {
padding: 18px 12px;
}
.nav-group {
margin: 0 0 18px;
}
.nav-group__label,
.nav-group__toggle {
color: #bdaeb4;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.nav-group__label {
padding: 0 10px 8px;
}
.nav__link {
border-radius: 4px;
color: #f8f2ed;
font-size: 14px;
margin: 2px 0;
min-height: 38px;
padding: 9px 10px;
}
.nav__link:hover,
.nav__link:focus-visible,
.nav__link.active {
background: #4c3d47;
color: #fff;
}
.nav__link.active {
box-shadow: inset 3px 0 0 var(--anouma-rose);
}
.nav__controls {
border-top: 1px solid #4b3f46;
padding: 14px 12px;
}
.app-header,
.step-nav {
background: var(--anouma-paper);
border-bottom: 1px solid var(--anouma-line);
}
.template-default__wrap,
.dashboard__wrap,
.collection-list__wrap,
.edit-view__wrap {
background: var(--anouma-cream);
}
.template-default__wrap {
min-height: 100vh;
}
.dashboard__header,
.collection-list__header,
.edit-view__header {
border-bottom: 1px solid var(--anouma-line);
margin-bottom: 26px;
padding-bottom: 18px;
}
.dashboard__header h1,
.collection-list__header h1,
.edit-view__header h1 {
color: var(--anouma-ink);
font-size: clamp(26px, 3vw, 38px);
font-weight: 600;
letter-spacing: -0.025em;
}
.card,
.dashboard__card,
.table-wrap,
.collection-list__table-wrap,
.field-type,
.document-fields__main {
border-color: var(--anouma-line);
}
.card,
.dashboard__card,
.table-wrap {
border-radius: 6px;
box-shadow: 0 8px 24px rgb(52 44 49 / 4%);
}
.btn {
border-radius: 4px;
font-weight: 600;
}
.btn--style-primary {
--bg-color: var(--anouma-mauve);
--hover-bg: var(--anouma-plum);
}
input,
textarea,
select,
.rs__control {
border-color: #d7c9c1;
border-radius: 4px;
}
input:focus,
textarea:focus,
select:focus,
.rs__control--is-focused {
border-color: var(--anouma-mauve);
box-shadow: 0 0 0 2px rgb(139 97 109 / 12%);
}
.field-label,
.field-label label {
color: var(--anouma-plum);
font-weight: 650;
}
.description,
.field-description {
color: #7b6b72;
}
.table thead th,
.table th {
background: #f1e9e4;
color: var(--anouma-plum);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.table tbody tr:hover {
background: #fbf7f4;
}
.anouma-admin-welcome {
background: var(--anouma-paper);
border: 1px solid var(--anouma-line);
border-left: 4px solid var(--anouma-mauve);
border-radius: 6px;
box-shadow: 0 12px 32px rgb(52 44 49 / 5%);
margin: 0 0 28px;
padding: 28px;
}
.anouma-admin-welcome__intro {
max-width: 680px;
}
.anouma-admin-eyebrow {
color: var(--anouma-mauve);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
margin: 0 0 8px;
text-transform: uppercase;
}
.anouma-admin-welcome h1 {
color: var(--anouma-ink);
font-size: clamp(25px, 3vw, 36px);
font-weight: 600;
letter-spacing: -0.03em;
margin: 0;
}
.anouma-admin-welcome__intro > p:last-child {
color: #776870;
font-size: 15px;
line-height: 1.6;
margin: 10px 0 0;
}
.anouma-admin-quicklinks {
display: grid;
gap: 10px;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-top: 26px;
}
.anouma-admin-quicklink {
background: #faf5f1;
border: 1px solid var(--anouma-line);
border-radius: 4px;
color: var(--anouma-ink);
display: flex;
flex-direction: column;
min-height: 154px;
padding: 16px;
text-decoration: none;
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
}
.anouma-admin-quicklink:hover,
.anouma-admin-quicklink:focus-visible {
background: #fffaf7;
border-color: var(--anouma-mauve);
color: var(--anouma-ink);
transform: translateY(-2px);
}
.anouma-admin-quicklink__number {
color: var(--anouma-rose);
font-family: var(--font-mono);
font-size: 12px;
margin-bottom: 24px;
}
.anouma-admin-quicklink__kicker {
color: var(--anouma-mauve);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.anouma-admin-quicklink strong {
font-size: 17px;
font-weight: 650;
margin-top: 5px;
}
.anouma-admin-quicklink > span:last-child {
color: #7b6b72;
font-size: 12px;
line-height: 1.45;
margin-top: 6px;
}
.anouma-custom-nav-item,
.anouma-custom-nav-action {
margin: 0 0 6px;
padding: 0;
}
.anouma-custom-nav-link,
.anouma-custom-nav-button {
align-items: center;
border: 1px solid #5a4a55;
border-radius: 4px;
display: flex;
font-size: 13px;
font-weight: 650;
gap: 10px;
min-height: 38px;
padding: 8px 10px;
text-decoration: none;
width: 100%;
}
.anouma-custom-nav-link {
color: #fffaf6;
}
.anouma-custom-nav-link:hover,
.anouma-custom-nav-link:focus-visible {
background: #4c3d47;
border-color: #7d6371;
color: #fff;
}
.anouma-custom-nav-link__icon {
color: var(--anouma-rose);
font-family: var(--font-mono);
font-size: 10px;
}
.anouma-custom-nav-button {
background: var(--anouma-mauve);
border-color: var(--anouma-mauve);
color: #fff;
cursor: pointer;
justify-content: flex-start;
}
.anouma-custom-nav-button:hover,
.anouma-custom-nav-button:focus-visible {
background: var(--anouma-plum);
border-color: var(--anouma-plum);
}
.anouma-custom-nav-button:disabled {
cursor: wait;
opacity: 0.7;
}
.anouma-custom-nav-input {
background: #fffaf6;
border: 1px solid #d7c9c1;
border-radius: 3px;
color: var(--anouma-ink);
font-family: var(--font-mono);
font-size: 10px;
margin-top: 6px;
padding: 6px 7px;
width: 100%;
}
.anouma-custom-nav-error {
color: #f1b5b5;
font-size: 11px;
line-height: 1.4;
margin: 6px 0 0;
}
@media (max-width: 1100px) {
.anouma-admin-quicklinks {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
.anouma-admin-welcome {
padding: 20px;
}
.anouma-admin-quicklinks {
grid-template-columns: 1fr;
}
}