From e627f7c372182a596ee38c3b933a237410235532 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 14 Aug 2026 22:27:49 +0200 Subject: [PATCH] Landingpages als eigene Gattung, erster Typ: Mehrere Links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die sechs noch offenen Typen haben kein fremdes Ziel, auf das man weiterleiten koennte — sie brauchen eine Seite. Dafuer bekommt der Typ-Vertrag `hasLandingPage`: /r/[slug] schickt solche Codes auf /s/[slug], statt `redirectTarget` zu befragen. Die oeffentlichen Seiten duerfen die Navigation der Anwendung nicht zeigen — wer einen Code scannt, hat mit „Verwaltung" nichts zu tun. Deshalb liegt das App-Chrome jetzt in der Routengruppe (app); das Wurzel-Layout traegt nur noch html und body. Die URLs aendern sich dadurch nicht. --- src/app/{ => (app)}/anmelden/page.tsx | 0 src/app/{ => (app)}/erstellen/[type]/page.tsx | 0 src/app/{ => (app)}/kein-zugriff/page.tsx | 0 src/app/{ => (app)}/konto/page.tsx | 0 src/app/(app)/layout.tsx | 116 ++++++++++++ src/app/{ => (app)}/meine-codes/page.tsx | 0 src/app/{ => (app)}/page.tsx | 0 .../{ => (app)}/verwaltung/benutzer/page.tsx | 0 .../{ => (app)}/verwaltung/domains/page.tsx | 0 .../{ => (app)}/verwaltung/gruppen/page.tsx | 0 src/app/{ => (app)}/verwaltung/page.tsx | 0 src/app/{ => (app)}/verwaltung/typen/page.tsx | 0 src/app/layout.tsx | 116 +----------- src/app/r/[slug]/route.ts | 23 ++- src/app/s/[slug]/page.tsx | 102 ++++++++++ src/components/landing/index.ts | 19 ++ src/components/landing/links-landing.tsx | 34 ++++ src/components/landing/shell.tsx | 66 +++++++ src/components/landing/types.ts | 14 ++ src/components/qr-forms/index.ts | 2 + src/components/qr-forms/links-form.tsx | 177 ++++++++++++++++++ src/lib/codes.ts | 9 +- src/lib/env.ts | 24 ++- src/lib/qr/registry.ts | 8 +- src/lib/qr/request.ts | 3 + src/lib/qr/type-module.ts | 14 ++ src/lib/qr/types/links.ts | 75 ++++++++ 27 files changed, 678 insertions(+), 124 deletions(-) rename src/app/{ => (app)}/anmelden/page.tsx (100%) rename src/app/{ => (app)}/erstellen/[type]/page.tsx (100%) rename src/app/{ => (app)}/kein-zugriff/page.tsx (100%) rename src/app/{ => (app)}/konto/page.tsx (100%) create mode 100644 src/app/(app)/layout.tsx rename src/app/{ => (app)}/meine-codes/page.tsx (100%) rename src/app/{ => (app)}/page.tsx (100%) rename src/app/{ => (app)}/verwaltung/benutzer/page.tsx (100%) rename src/app/{ => (app)}/verwaltung/domains/page.tsx (100%) rename src/app/{ => (app)}/verwaltung/gruppen/page.tsx (100%) rename src/app/{ => (app)}/verwaltung/page.tsx (100%) rename src/app/{ => (app)}/verwaltung/typen/page.tsx (100%) create mode 100644 src/app/s/[slug]/page.tsx create mode 100644 src/components/landing/index.ts create mode 100644 src/components/landing/links-landing.tsx create mode 100644 src/components/landing/shell.tsx create mode 100644 src/components/landing/types.ts create mode 100644 src/components/qr-forms/links-form.tsx create mode 100644 src/lib/qr/types/links.ts diff --git a/src/app/anmelden/page.tsx b/src/app/(app)/anmelden/page.tsx similarity index 100% rename from src/app/anmelden/page.tsx rename to src/app/(app)/anmelden/page.tsx diff --git a/src/app/erstellen/[type]/page.tsx b/src/app/(app)/erstellen/[type]/page.tsx similarity index 100% rename from src/app/erstellen/[type]/page.tsx rename to src/app/(app)/erstellen/[type]/page.tsx diff --git a/src/app/kein-zugriff/page.tsx b/src/app/(app)/kein-zugriff/page.tsx similarity index 100% rename from src/app/kein-zugriff/page.tsx rename to src/app/(app)/kein-zugriff/page.tsx diff --git a/src/app/konto/page.tsx b/src/app/(app)/konto/page.tsx similarity index 100% rename from src/app/konto/page.tsx rename to src/app/(app)/konto/page.tsx diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..a66a5c5 --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,116 @@ +import Link from "next/link"; + +import { LogoutButton } from "@/components/auth-forms"; +import { Icon } from "@/components/icons"; +import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor"; +import { can } from "@/lib/auth/policy"; + +// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert +// werden. +export const dynamic = "force-dynamic"; + +const NAV_LINK = + "rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"; + +/** + * Rahmen der eigentlichen Anwendung: Kopfzeile mit Navigation, Inhalt, + * Fußzeile. Die öffentlichen Landingpages liegen bewusst außerhalb dieser + * Gruppe und bekommen davon nichts ab. + */ +export default async function AppLayout({ children }: LayoutProps<"/">) { + const actor = await getActor(); + const signedIn = actor.kind !== "anonymous"; + const showAdmin = can(actor, "instance:manage"); + + return ( +
+
+
+ + + + + {/* Auf schmalen Geräten reicht das Zeichen — sonst passt die + Navigation nicht mehr in eine Zeile. */} + QR-Code-Generator + + + +
+
+ +
{children}
+ +
+
+ QR-Code-Generator · statische und dynamische QR-Codes +
+
+
+ ); +} diff --git a/src/app/meine-codes/page.tsx b/src/app/(app)/meine-codes/page.tsx similarity index 100% rename from src/app/meine-codes/page.tsx rename to src/app/(app)/meine-codes/page.tsx diff --git a/src/app/page.tsx b/src/app/(app)/page.tsx similarity index 100% rename from src/app/page.tsx rename to src/app/(app)/page.tsx diff --git a/src/app/verwaltung/benutzer/page.tsx b/src/app/(app)/verwaltung/benutzer/page.tsx similarity index 100% rename from src/app/verwaltung/benutzer/page.tsx rename to src/app/(app)/verwaltung/benutzer/page.tsx diff --git a/src/app/verwaltung/domains/page.tsx b/src/app/(app)/verwaltung/domains/page.tsx similarity index 100% rename from src/app/verwaltung/domains/page.tsx rename to src/app/(app)/verwaltung/domains/page.tsx diff --git a/src/app/verwaltung/gruppen/page.tsx b/src/app/(app)/verwaltung/gruppen/page.tsx similarity index 100% rename from src/app/verwaltung/gruppen/page.tsx rename to src/app/(app)/verwaltung/gruppen/page.tsx diff --git a/src/app/verwaltung/page.tsx b/src/app/(app)/verwaltung/page.tsx similarity index 100% rename from src/app/verwaltung/page.tsx rename to src/app/(app)/verwaltung/page.tsx diff --git a/src/app/verwaltung/typen/page.tsx b/src/app/(app)/verwaltung/typen/page.tsx similarity index 100% rename from src/app/verwaltung/typen/page.tsx rename to src/app/(app)/verwaltung/typen/page.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1fb47f2..7dfff57 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,5 @@ import type { Metadata } from "next"; -import Link from "next/link"; -import { LogoutButton } from "@/components/auth-forms"; -import { Icon } from "@/components/icons"; -import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor"; -import { can } from "@/lib/auth/policy"; import "./globals.css"; export const metadata: Metadata = { @@ -16,109 +11,18 @@ export const metadata: Metadata = { "QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.", }; -// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert -// werden. -export const dynamic = "force-dynamic"; - -const NAV_LINK = - "rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"; - -export default async function RootLayout({ children }: LayoutProps<"/">) { - const actor = await getActor(); - const signedIn = actor.kind !== "anonymous"; - const showAdmin = can(actor, "instance:manage"); - +/** + * Wurzel-Layout — bewusst nur `html` und `body`. + * + * Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in + * `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]` + * bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und + * „Meine Codes“ nichts zu suchen. + */ +export default function RootLayout({ children }: LayoutProps<"/">) { return ( - -
-
- - - - - {/* Auf schmalen Geräten reicht das Zeichen — sonst passt die - Navigation nicht mehr in eine Zeile. */} - QR-Code-Generator - - - -
-
- -
{children}
- - - + {children} ); } diff --git a/src/app/r/[slug]/route.ts b/src/app/r/[slug]/route.ts index ad197c5..b5283bc 100644 --- a/src/app/r/[slug]/route.ts +++ b/src/app/r/[slug]/route.ts @@ -81,14 +81,21 @@ export async function GET( } let target: string; - try { - // Der User-Agent geht mit: Typen wie der App-Link entscheiden daran, - // wohin die Weiterleitung führt. - target = qrType.redirectTarget(code.data, { - userAgent: request.headers.get("user-agent") ?? undefined, - }); - } catch { - return notFound("Das Ziel dieses Codes ist nicht mehr gültig."); + if (qrType.hasLandingPage) { + // Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ, + // damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch + // wenn das eine nicht registrierte ist, etwa eine IP im LAN). + target = `/s/${slug}`; + } else { + try { + // Der User-Agent geht mit: Typen wie der App-Link entscheiden daran, + // wohin die Weiterleitung führt. + target = qrType.redirectTarget(code.data, { + userAgent: request.headers.get("user-agent") ?? undefined, + }); + } catch { + return notFound("Das Ziel dieses Codes ist nicht mehr gültig."); + } } // Der Zähler darf die Weiterleitung nicht ausbremsen oder blockieren. diff --git a/src/app/s/[slug]/page.tsx b/src/app/s/[slug]/page.tsx new file mode 100644 index 0000000..69fabaa --- /dev/null +++ b/src/app/s/[slug]/page.tsx @@ -0,0 +1,102 @@ +import type { Metadata } from "next"; +import { headers } from "next/headers"; + +import { qrLandings } from "@/components/landing"; +import { LandingNotice } from "@/components/landing/shell"; +import { prisma } from "@/lib/db"; +import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains"; +import { getQrType } from "@/lib/qr/registry"; +import { isValidSlug } from "@/lib/slug"; + +export const dynamic = "force-dynamic"; + +type PageProps = { params: Promise<{ slug: string }> }; + +/** + * Öffentliche Landingpage eines dynamischen Codes. + * + * Hierher schickt `/r/[slug]` alle Typen, die keine fremde Adresse haben, + * sondern eine Seite: Linksammlungen, hinterlegte Dateien, Feedback. + * + * Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`. + * Sonst zählte jedes Neuladen dieser Seite als weiterer Scan. + */ +async function loadCode(slug: string) { + if (!isValidSlug(slug)) return null; + + const matched = await resolveRequestDomain(await headers()); + const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true }; + if (!domain.isActive) return null; + + return prisma.qrCode.findUnique({ + where: { domainId_slug: { domainId: domain.id, slug } }, + }); +} + +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { slug } = await params; + const code = await loadCode(slug); + const qrType = code ? getQrType(code.type) : undefined; + + if (!code || !qrType?.hasLandingPage) { + return { title: "Nicht gefunden" }; + } + + let title = qrType.title; + try { + title = qrType.summary(code.data); + } catch { + // Daten passen nicht mehr zum Schema — der Typname tut es auch. + } + // Absolut, damit der Titel nicht mit dem Anwendungsnamen ergänzt wird: + // Wer hier landet, hat mit dem Generator nichts zu tun. + return { title: { absolute: title } }; +} + +export default async function LandingPage({ params }: PageProps) { + const { slug } = await params; + const code = await loadCode(slug); + + if (!code) { + return ( + + Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an + denjenigen, der ihn erstellt hat. + + ); + } + + const qrType = getQrType(code.type); + const Landing = qrType?.hasLandingPage ? qrLandings[qrType.id] : undefined; + + if (!qrType || !Landing) { + return ( + + Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen, + der ihn erstellt hat. + + ); + } + + // Alte Datensätze können nach einer Schema-Änderung unbrauchbar sein. Das + // ist kein Grund, den Besuchern einen Absturz zu zeigen. + if (!qrType.validate(code.data).ok) { + return ( + + Die hinterlegten Inhalte sind unvollständig. Bitte wende dich an + denjenigen, der den Code erstellt hat. + + ); + } + + return ( + + ); +} diff --git a/src/components/landing/index.ts b/src/components/landing/index.ts new file mode 100644 index 0000000..d1f9fc1 --- /dev/null +++ b/src/components/landing/index.ts @@ -0,0 +1,19 @@ +import type { ComponentType } from "react"; + +import { LinksLanding } from "@/components/landing/links-landing"; +import type { LandingProps } from "@/components/landing/types"; +import type { QrTypeId } from "@/lib/qr/type-module"; + +/** + * Zuordnung QR-Typ → öffentliche Landingpage. + * + * Dritter Teil des Musters aus Typ-Registry (`src/lib/qr/registry.ts`) und + * Formular-Registry (`src/components/qr-forms/index.ts`): Ein Typ, der + * `hasLandingPage` meldet, braucht hier genau einen Eintrag. + */ +export const qrLandings: Partial>> = + { + links: LinksLanding, + }; + +export type { LandingProps }; diff --git a/src/components/landing/links-landing.tsx b/src/components/landing/links-landing.tsx new file mode 100644 index 0000000..1d41043 --- /dev/null +++ b/src/components/landing/links-landing.tsx @@ -0,0 +1,34 @@ +import { Icon } from "@/components/icons"; +import { LandingShell } from "@/components/landing/shell"; +import type { LandingProps } from "@/components/landing/types"; +import { linksDataSchema } from "@/lib/qr/types/links"; + +export function LinksLanding({ data }: LandingProps) { + const { title, description, links } = linksDataSchema.parse(data); + + return ( + + + + ); +} diff --git a/src/components/landing/shell.tsx b/src/components/landing/shell.tsx new file mode 100644 index 0000000..37070a0 --- /dev/null +++ b/src/components/landing/shell.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from "react"; + +/** + * Rahmen aller öffentlichen Landingpages. + * + * Diese Seiten sehen Leute, die einen Code gescannt haben — meist auf dem + * Handy, oft unterwegs, ohne Bezug zu dieser Anwendung. Deshalb: eine Spalte, + * große Schrift, keine Navigation, kein Hinweis auf die Verwaltung. + */ +export function LandingShell({ + title, + subtitle, + children, + footer, +}: { + title: string; + subtitle?: string | null; + children: ReactNode; + footer?: ReactNode; +}) { + return ( +
+
+
+

+ {title} +

+ {subtitle ? ( +

{subtitle}

+ ) : null} +
+ {children} +
+ {footer ? ( +
+ {footer} +
+ ) : null} +
+ ); +} + +/** + * Ein Fehlerbild im selben Gewand — etwa wenn ein Code gelöscht wurde. + * Bewusst ohne Schuldzuweisung: wer hier landet, kann nichts dafür. + */ +export function LandingNotice({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+
+

+ {title} +

+

+ {children} +

+
+
+ ); +} diff --git a/src/components/landing/types.ts b/src/components/landing/types.ts new file mode 100644 index 0000000..2c625cb --- /dev/null +++ b/src/components/landing/types.ts @@ -0,0 +1,14 @@ +/** + * Was eine Landingpage über den Code weiß, den jemand gescannt hat. + * + * `data` ist bereits vom Typ-Modul geprüft; die Komponente parst es nur noch + * einmal mit ihrem eigenen Schema, um typisiert damit zu arbeiten. + */ +export type LandingProps = { + data: unknown; + /** Interner Name des Codes — nicht öffentlich, nur als Rückfallebene. */ + label: string | null; + slug: string; + /** Datenbank-Id des Codes, für Landingpages mit Rückmeldung. */ + codeId: string; +}; diff --git a/src/components/qr-forms/index.ts b/src/components/qr-forms/index.ts index df4a4d9..78ca41e 100644 --- a/src/components/qr-forms/index.ts +++ b/src/components/qr-forms/index.ts @@ -1,5 +1,6 @@ import type { ComponentType } from "react"; +import { LinksForm } from "@/components/qr-forms/links-form"; import { PaymentForm } from "@/components/qr-forms/payment-form"; import { AppForm, @@ -25,6 +26,7 @@ export const qrForms: Partial>> = { website: WebsiteForm, vcard: VCardForm, app: AppForm, + links: LinksForm, whatsapp: WhatsappForm, email: EmailForm, sms: SmsForm, diff --git a/src/components/qr-forms/links-form.tsx b/src/components/qr-forms/links-form.tsx new file mode 100644 index 0000000..62c1d1d --- /dev/null +++ b/src/components/qr-forms/links-form.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { Icon } from "@/components/icons"; +import type { QrFormProps } from "@/components/qr-forms/types"; +import { Button, Field, TextArea, TextInput } from "@/components/ui"; +import { MAX_LINKS, type LinkEntry } from "@/lib/qr/types/links"; + +/** + * Liest die Linkliste aus den Formulardaten heraus. + * + * Die Daten kommen als `QrFormData` — ein flaches Objekt ohne Garantien. + * Beim Bearbeiten eines gespeicherten Codes stammen sie aus der Datenbank und + * können nach einer Schema-Änderung unvollständig sein; deshalb hier prüfen + * statt annehmen. + */ +function readLinks(value: unknown): LinkEntry[] { + if (!Array.isArray(value)) return [{ label: "", url: "" }]; + const entries = value.map((entry) => { + const record = (entry ?? {}) as Record; + return { + label: typeof record.label === "string" ? record.label : "", + url: typeof record.url === "string" ? record.url : "", + }; + }); + return entries.length > 0 ? entries : [{ label: "", url: "" }]; +} + +export function LinksForm({ value, onChange, error, disabled }: QrFormProps) { + const title = typeof value.title === "string" ? value.title : ""; + const description = + typeof value.description === "string" ? value.description : ""; + const links = readLinks(value.links); + + const setLinks = (next: LinkEntry[]) => onChange({ ...value, links: next }); + + const updateLink = (index: number, patch: Partial) => + setLinks(links.map((link, i) => (i === index ? { ...link, ...patch } : link))); + + const move = (index: number, direction: -1 | 1) => { + const target = index + direction; + if (target < 0 || target >= links.length) return; + const next = [...links]; + [next[index], next[target]] = [next[target], next[index]]; + setLinks(next); + }; + + return ( +
+ + onChange({ ...value, title: event.target.value })} + /> + + + +