Sprechende Kurz-Links: selbst vergeben, aendern, frueher gueltig halten

Schliesst Issue #1. Der Wunsch betraf den Kurz-Link, nicht das Ziel: Er soll
sich von Anfang an bestimmen und spaeter aendern lassen.

Beim Anlegen gibt es dafuer ein Feld, im Bearbeiten-Dialog laesst er sich
aendern. Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und „/" als
Trenner — damit sind Adressen wie kunde-a/sommer moeglich. Umlaute bleiben
draussen, weil sie in Adressen unleserlich kodiert werden; Gross- und
Kleinschreibung ist beim Aufrufen egal.

Beim Aendern bleibt die vorherige Adresse gueltig. Das ist der Kern: Auf
gedruckten Codes steht die alte, und genau deren Bestand ist der Sinn eines
dynamischen Codes. Frueher vergebene Adressen stehen im Dialog und lassen
sich einzeln freigeben — fuer den Tippfehler, mit dem noch nichts gedruckt
ist. Solange eine Adresse gehalten wird, kann sie kein anderer Code
uebernehmen; sonst liessen sich fremde gedruckte Codes umlenken.

Vergeben und Aendern ist standardmaessig Administratoren vorbehalten. Ein
Kurz-Link ist eine oeffentliche Adresse, und wer ihn aendert, kann Aushaenge
entwerten. Unter Verwaltung -> Einstellungen laesst sich das fuer Bearbeiter
freigeben; ohne das Recht erscheint das Feld gar nicht, Codes anlegen und
deren Ziel aendern geht weiterhin. Eigene Aktion in der Policy statt Teil von
codes:update, weil sie mehr wiegt.

Technisch: /r und /s sind Sammelrouten, weil ein Kurz-Link „/" enthalten
darf. Die Eindeutigkeit gilt zusaetzlich auf der kleingeschriebenen Form; die
Migration bricht ab, falls der Bestand das nicht zulaesst, statt still Daten
zu verlieren. Der bestehende exakte Index bleibt — er traegt den haeufigen
Fall, den gescannten Code.
This commit is contained in:
Florian Wilde
2026-08-17 10:41:57 +02:00
parent c38b27ca85
commit 46e7eb0b59
24 changed files with 917 additions and 49 deletions
+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 { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic";
// Sammelroute, weil ein Kurz-Link „/" enthalten darf.
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(abschnitte: string[] | undefined) {
const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) return null;
const matched = await resolveRequestDomain(await headers());
const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
if (!domain.isActive) return null;
return (await findCodeBySlug(domain.id, slug))?.code ?? null;
}
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}
/>
);
}