Landingpages als eigene Gattung, erster Typ: Mehrere Links

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.
This commit is contained in:
Florian Wilde
2026-08-14 22:27:49 +02:00
parent b3d386b0d2
commit e627f7c372
27 changed files with 678 additions and 124 deletions
+116
View File
@@ -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 (
<div className="flex min-h-full flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6">
<Link
href="/"
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
>
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
<Icon name="qr" className="size-5" />
</span>
{/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
Navigation nicht mehr in eine Zeile. */}
<span className="hidden sm:inline">QR-Code-Generator</span>
</Link>
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
{signedIn ? (
<>
<Link href="/" className={NAV_LINK}>
Erstellen
</Link>
<Link href="/meine-codes" className={NAV_LINK}>
Meine Codes
</Link>
{showAdmin ? (
<Link href="/verwaltung" className={NAV_LINK}>
Verwaltung
</Link>
) : null}
</>
) : null}
{isAuthEnabled() && actor.kind === "user" ? (
<>
<span
aria-hidden="true"
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
/>
<Link
href="/konto"
title={`Konto von ${actor.user.email}`}
className="flex max-w-[13rem] items-center gap-2 rounded-lg px-2.5 py-2 text-slate-700 transition-colors hover:bg-slate-100"
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-[0.7rem] font-semibold text-indigo-700 uppercase">
{actorLabel(actor).slice(0, 2)}
</span>
<span className="hidden truncate sm:inline">
{actorLabel(actor)}
</span>
</Link>
<LogoutButton />
</>
) : null}
{isAuthEnabled() && actor.kind === "anonymous" ? (
<Link href="/anmelden" className={NAV_LINK}>
Anmelden
</Link>
) : null}
{/* Ohne Anmeldung gibt es kein Konto. Das gehört sichtbar
gesagt — sonst sucht man den Abmelden-Knopf vergeblich. */}
{!isAuthEnabled() ? (
<>
<span
aria-hidden="true"
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
/>
<Link
href="/konto"
title="Die Anmeldung ist ausgeschaltet — hier steht, wie du sie einschaltest"
className="rounded-lg px-3 py-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
Ohne Anmeldung
</Link>
</>
) : null}
</nav>
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="border-t border-slate-200 bg-white">
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
QR-Code-Generator · statische und dynamische QR-Codes
</div>
</footer>
</div>
);
}
+10 -106
View File
@@ -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 (
<html lang="de" className="h-full">
<body className="flex min-h-full flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6">
<Link
href="/"
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
>
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
<Icon name="qr" className="size-5" />
</span>
{/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
Navigation nicht mehr in eine Zeile. */}
<span className="hidden sm:inline">QR-Code-Generator</span>
</Link>
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
{signedIn ? (
<>
<Link href="/" className={NAV_LINK}>
Erstellen
</Link>
<Link href="/meine-codes" className={NAV_LINK}>
Meine Codes
</Link>
{showAdmin ? (
<Link href="/verwaltung" className={NAV_LINK}>
Verwaltung
</Link>
) : null}
</>
) : null}
{isAuthEnabled() && actor.kind === "user" ? (
<>
<span
aria-hidden="true"
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
/>
<Link
href="/konto"
title={`Konto von ${actor.user.email}`}
className="flex max-w-[13rem] items-center gap-2 rounded-lg px-2.5 py-2 text-slate-700 transition-colors hover:bg-slate-100"
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-[0.7rem] font-semibold text-indigo-700 uppercase">
{actorLabel(actor).slice(0, 2)}
</span>
<span className="hidden truncate sm:inline">
{actorLabel(actor)}
</span>
</Link>
<LogoutButton />
</>
) : null}
{isAuthEnabled() && actor.kind === "anonymous" ? (
<Link href="/anmelden" className={NAV_LINK}>
Anmelden
</Link>
) : null}
{/* Ohne Anmeldung gibt es kein Konto. Das gehört sichtbar
gesagt — sonst sucht man den Abmelden-Knopf vergeblich. */}
{!isAuthEnabled() ? (
<>
<span
aria-hidden="true"
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
/>
<Link
href="/konto"
title="Die Anmeldung ist ausgeschaltet — hier steht, wie du sie einschaltest"
className="rounded-lg px-3 py-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
Ohne Anmeldung
</Link>
</>
) : null}
</nav>
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="border-t border-slate-200 bg-white">
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
QR-Code-Generator · statische und dynamische QR-Codes
</div>
</footer>
</body>
<body className="min-h-full">{children}</body>
</html>
);
}
+15 -8
View File
@@ -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.
+102
View File
@@ -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<Metadata> {
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 (
<LandingNotice title="Diese Seite gibt es nicht">
Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an
denjenigen, der ihn erstellt hat.
</LandingNotice>
);
}
const qrType = getQrType(code.type);
const Landing = qrType?.hasLandingPage ? qrLandings[qrType.id] : undefined;
if (!qrType || !Landing) {
return (
<LandingNotice title="Diese Seite gibt es nicht">
Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen,
der ihn erstellt hat.
</LandingNotice>
);
}
// 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 (
<LandingNotice title="Diese Seite ist gerade nicht verfügbar">
Die hinterlegten Inhalte sind unvollständig. Bitte wende dich an
denjenigen, der den Code erstellt hat.
</LandingNotice>
);
}
return (
<Landing
data={code.data}
label={code.label}
slug={code.slug}
codeId={code.id}
/>
);
}