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
+177
View File
@@ -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<string, unknown>;
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<LinkEntry>) =>
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 (
<div className="space-y-5">
<Field
label="Überschrift der Seite"
htmlFor="links-title"
hint="Steht groß über der Linkliste, z. B. der Projektname."
>
<TextInput
id="links-title"
value={title}
disabled={disabled}
maxLength={80}
placeholder="Projekt Nordwind"
onChange={(event) => onChange({ ...value, title: event.target.value })}
/>
</Field>
<Field
label="Kurzer Text (optional)"
htmlFor="links-description"
hint="Eine Zeile zur Einordnung — was findet man hier?"
>
<TextArea
id="links-description"
rows={2}
value={description}
disabled={disabled}
maxLength={300}
placeholder="Alle Unterlagen zum Projekt auf einen Blick."
onChange={(event) =>
onChange({ ...value, description: event.target.value })
}
/>
</Field>
<div>
<div className="mb-2 flex items-baseline justify-between gap-3">
<span className="text-sm font-medium text-slate-700">Links</span>
<span className="text-xs text-slate-500">
{links.length} von {MAX_LINKS}
</span>
</div>
<ul className="space-y-3">
{links.map((link, index) => (
<li
key={index}
className="rounded-xl border border-slate-200 bg-slate-50/70 p-3"
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-2">
<TextInput
aria-label={`Beschriftung von Link ${index + 1}`}
value={link.label}
disabled={disabled}
maxLength={80}
placeholder="Beschriftung, z. B. Preisliste"
onChange={(event) =>
updateLink(index, { label: event.target.value })
}
/>
<TextInput
aria-label={`Adresse von Link ${index + 1}`}
inputMode="url"
spellCheck={false}
value={link.url}
disabled={disabled}
maxLength={2048}
placeholder="https://example.com/preisliste.pdf"
onChange={(event) =>
updateLink(index, { url: event.target.value })
}
/>
</div>
<div className="flex shrink-0 flex-col gap-0.5">
<button
type="button"
disabled={disabled || index === 0}
onClick={() => move(index, -1)}
aria-label={`Link ${index + 1} nach oben`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="arrowLeft" className="size-4 rotate-90" />
</button>
<button
type="button"
disabled={disabled || index === links.length - 1}
onClick={() => move(index, 1)}
aria-label={`Link ${index + 1} nach unten`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="arrowLeft" className="size-4 -rotate-90" />
</button>
<button
type="button"
disabled={disabled || links.length === 1}
onClick={() => setLinks(links.filter((_, i) => i !== index))}
aria-label={`Link ${index + 1} entfernen`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="trash" className="size-4" />
</button>
</div>
</div>
</li>
))}
</ul>
{links.length < MAX_LINKS ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={disabled}
onClick={() => setLinks([...links, { label: "", url: "" }])}
className="mt-3"
>
Link hinzufügen
</Button>
) : null}
{error ? (
<p role="alert" className="mt-2 text-sm text-red-600">
{error}
</p>
) : null}
</div>
</div>
);
}