Verwaltung: QR-Typen einzeln freigeben oder ausblenden

Neue Seite Verwaltung -> QR-Typen mit einem Schalter je Typ. Abgeschaltete
Typen verschwinden vollstaendig von der Startseite und lassen sich nicht
mehr neu anlegen.

- Neue Tabelle QrTypeSetting; fehlender Eintrag heisst freigegeben, damit
  eine frische Installation ohne Vorbelegung laeuft und spaetere Typen
  automatisch erscheinen.
- Registry-Feld 'active' heisst jetzt 'implemented' und beschreibt nur noch,
  ob der Typ ausprogrammiert ist. Ob er erscheint, entscheidet zusaetzlich
  die Freigabe aus der Datenbank (src/lib/qr/availability.ts).
- Bestehende dynamische Codes bleiben von einer Abschaltung unberuehrt: die
  Weiterleitung funktioniert weiter und Bearbeiten bleibt moeglich. Ein
  gedruckter Code darf durch einen Schalter nicht ungueltig werden.
- resolveQrRequest ist dadurch asynchron geworden; QrRequestError liegt jetzt
  in type-module.ts, sonst gaebe es einen Ringschluss mit availability.ts.
- Startseite meldet jetzt auch den Fall, dass nur noch Platzhalter
  freigegeben sind - vorher standen dort 14 tote Kacheln ohne Erklaerung.
- Kopfzeile: Schriftzug auf schmalen Geraeten ausgeblendet, sonst passte die
  dritte Navigationsschaltflaeche nicht mehr in die Zeile (7px Ueberlauf).
This commit is contained in:
Florian Wilde
2026-08-14 10:48:21 +02:00
parent 1e9e37f08d
commit c6da20f419
18 changed files with 684 additions and 148 deletions
+51
View File
@@ -0,0 +1,51 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import {
getQrTypeAvailability,
setQrTypeEnabled,
} from "@/lib/qr/availability";
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
const updateSchema = z.object({
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
enabled: z.boolean({ error: "Der Schalter muss wahr oder falsch sein." }),
});
/** Zustand aller QR-Typen für die Verwaltung. */
export async function GET(): Promise<Response> {
try {
const types = await getQrTypeAvailability();
return Response.json({
types: types.map(({ qrType, implemented, enabled, usable }) => ({
id: qrType.id,
title: qrType.title,
description: qrType.description,
icon: qrType.icon,
implemented,
enabled,
usable,
})),
});
} catch (error) {
return errorResponse(error);
}
}
/** Einen Typ freigeben oder abschalten. */
export async function PATCH(request: NextRequest): Promise<Response> {
try {
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
await setQrTypeEnabled(parsed.data.type, parsed.data.enabled);
return Response.json({ type: parsed.data.type, enabled: parsed.data.enabled });
} catch (error) {
return errorResponse(error);
}
}
+6 -8
View File
@@ -5,9 +5,9 @@ import type { Prisma } from "@/generated/prisma/client";
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireImplementedQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design";
import { getActiveQrType } from "@/lib/qr/registry";
import { QrRequestError } from "@/lib/qr/request";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
@@ -60,12 +60,10 @@ export async function PATCH(
const update: Prisma.QrCodeUpdateInput = {};
if (parsed.data.data !== undefined) {
const qrType = getActiveQrType(existing.type);
if (!qrType) {
throw new QrRequestError(
"Der Typ dieses Codes wird in dieser Version nicht unterstützt.",
);
}
// Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
// Verwaltung abgeschalteter Typ soll bestehende Codes nicht
// unbearbeitbar machen — sie sind längst gedruckt.
const qrType = requireImplementedQrType(existing.type);
const validated = qrType.validate(parsed.data.data);
if (!validated.ok) {
return jsonError(validated.message);
+3 -10
View File
@@ -4,10 +4,9 @@ import { z } from "zod";
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireAvailableQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design";
import { getActiveQrType } from "@/lib/qr/registry";
import { QrRequestError } from "@/lib/qr/request";
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
import { generateSlug } from "@/lib/slug";
export const dynamic = "force-dynamic";
@@ -51,13 +50,7 @@ export async function POST(request: NextRequest): Promise<Response> {
}
const { type, design, label } = parsed.data;
const qrType = getActiveQrType(type);
if (!qrType) {
throw new QrRequestError(
"Dieser QR-Typ ist in dieser Version noch nicht verfügbar.",
404,
);
}
const qrType = await requireAvailableQrType(type);
if (!qrType.supportsDynamic) {
throw new QrRequestError(
`Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`,
+1 -1
View File
@@ -31,7 +31,7 @@ export async function POST(request: NextRequest): Promise<Response> {
);
}
const { qrType, data, content } = resolveQrRequest(parsed.data);
const { qrType, data, content } = await resolveQrRequest(parsed.data);
const body =
format === "png"
+1 -1
View File
@@ -15,7 +15,7 @@ export async function POST(request: NextRequest): Promise<Response> {
return jsonError(firstZodMessage(parsed.error));
}
const { content } = resolveQrRequest(parsed.data);
const { content } = await resolveQrRequest(parsed.data);
const svg = await renderSvg(content, parsed.data.design);
return Response.json({
+50 -29
View File
@@ -4,14 +4,13 @@ import { notFound } from "next/navigation";
import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator";
import { QR_TYPE_IDS, getActiveQrType, getQrType } from "@/lib/qr/registry";
import { getQrTypeState } from "@/lib/qr/availability";
import { getQrType } from "@/lib/qr/registry";
type Props = { params: Promise<{ type: string }> };
/** Alle bekannten Typen vorab erzeugen — auch die noch inaktiven. */
export function generateStaticParams() {
return QR_TYPE_IDS.map((type) => ({ type }));
}
// Die Freigabe der Typen kommt aus der Datenbank und ist jederzeit änderbar.
export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { type } = await params;
@@ -19,38 +18,60 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return { title: qrType ? `${qrType.title}-QR-Code` : "QR-Code erstellen" };
}
/** Erklärseite statt 404 — der Typ existiert, ist nur (noch) nicht nutzbar. */
function Hinweis({
icon,
titel,
text,
}: {
icon: Parameters<typeof Icon>[0]["name"];
titel: string;
text: string;
}) {
return (
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6">
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<Icon name={icon} className="size-7" />
</span>
<h1 className="mt-5 text-2xl font-bold text-slate-900">{titel}</h1>
<p className="mt-2 text-slate-600">{text}</p>
<Link
href="/"
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
Zur Typ-Auswahl
</Link>
</div>
);
}
export default async function CreatePage({ params }: Props) {
const { type } = await params;
const qrType = getQrType(type);
const state = await getQrTypeState(type);
if (!qrType) {
if (!state) {
notFound();
}
// Registrierte, aber noch nicht implementierte Typen bekommen eine
// erklärende Seite statt einer 404.
if (!getActiveQrType(type)) {
if (!state.enabled) {
return (
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6">
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<Icon name={qrType.icon} className="size-7" />
</span>
<h1 className="mt-5 text-2xl font-bold text-slate-900">
„{qrType.title}“ ist bald verfügbar
</h1>
<p className="mt-2 text-slate-600">
Dieser QR-Typ folgt in Version 2. In Version 1 steht der Typ „Website“
zur Verfügung.
</p>
<Link
href="/erstellen/website"
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
Website-QR-Code erstellen
</Link>
</div>
<Hinweis
icon="lock"
titel={`„${state.qrType.title}“ ist abgeschaltet`}
text="Dieser QR-Typ wurde in der Verwaltung abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen wieder freigeben."
/>
);
}
return <QrGenerator typeId={qrType.id} />;
if (!state.implemented) {
return (
<Hinweis
icon={state.qrType.icon}
titel={`„${state.qrType.title}“ ist bald verfügbar`}
text="Dieser QR-Typ folgt in einer späteren Version. Bereits verfügbare Typen findest du in der Typ-Auswahl."
/>
);
}
return <QrGenerator typeId={state.qrType.id} />;
}
+11 -3
View File
@@ -18,7 +18,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
<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 items-center justify-between gap-4 px-4 py-3.5 sm:px-6">
<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"
@@ -26,9 +26,11 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
<Icon name="qr" className="size-5" />
</span>
QR-Code-Generator
{/* 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 items-center gap-1 text-sm font-medium">
<nav className="flex items-center gap-0.5 text-sm font-medium sm:gap-1">
<Link
href="/"
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
@@ -41,6 +43,12 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
>
Meine Codes
</Link>
<Link
href="/verwaltung/typen"
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Verwaltung
</Link>
</nav>
</div>
</header>
+99 -66
View File
@@ -1,10 +1,17 @@
import Link from "next/link";
import { Icon } from "@/components/icons";
import { Badge } from "@/components/ui";
import { qrTypeList } from "@/lib/qr/registry";
import { Badge, Notice } from "@/components/ui";
import { getVisibleQrTypes } from "@/lib/qr/availability";
// Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit
// änderbar — die Startseite darf deshalb nicht vorgerendert werden.
export const dynamic = "force-dynamic";
export default async function HomePage() {
const types = await getVisibleQrTypes();
const usableCount = types.filter((entry) => entry.usable).length;
export default function HomePage() {
return (
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<div className="max-w-2xl">
@@ -18,76 +25,102 @@ export default function HomePage() {
</p>
</div>
<ul className="mt-9 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{qrTypeList.map((type) => {
const tile = (
<>
<span
className={
type.active
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600"
: "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
}
>
<Icon name={type.icon} className="size-6" />
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span
className={
type.active
? "font-semibold text-slate-900"
: "font-semibold text-slate-500"
}
>
{type.title}
</span>
{type.active ? (
<Badge tone="popular">Beliebt</Badge>
) : (
<Badge tone="soon">Bald verfügbar</Badge>
)}
</span>
{usableCount === 0 ? (
<div className="mt-9 max-w-2xl">
<Notice tone="warning">
{types.length === 0
? "Zurzeit ist kein QR-Typ freigegeben."
: "Zurzeit ist kein QR-Typ zum Erstellen freigegeben — die unten gezeigten Typen folgen erst in einer späteren Version."}{" "}
Unter{" "}
<Link href="/verwaltung/typen" className="font-medium underline">
Verwaltung → QR-Typen
</Link>{" "}
lassen sich Typen wieder freigeben.
</Notice>
</div>
) : null}
{types.length === 0 ? null : (
<ul className="mt-9 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{types.map(({ qrType, usable }) => {
const tile = (
<>
<span
className={
type.active
? "mt-1 block text-sm text-slate-600"
: "mt-1 block text-sm text-slate-400"
usable
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600"
: "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
}
>
{type.description}
<Icon name={qrType.icon} className="size-6" />
</span>
</span>
</>
);
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span
className={
usable
? "font-semibold text-slate-900"
: "font-semibold text-slate-500"
}
>
{qrType.title}
</span>
{usable ? (
<Badge tone="popular">Beliebt</Badge>
) : (
<Badge tone="soon">Bald verfügbar</Badge>
)}
</span>
<span
className={
usable
? "mt-1 block text-sm text-slate-600"
: "mt-1 block text-sm text-slate-400"
}
>
{qrType.description}
</span>
</span>
</>
);
return (
<li key={type.id}>
{type.active ? (
<Link
href={`/erstellen/${type.id}`}
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
>
{tile}
</Link>
) : (
<div
aria-disabled="true"
title={`„${type.title}“ folgt in Version 2`}
className="flex h-full cursor-not-allowed items-start gap-4 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-4"
>
{tile}
</div>
)}
</li>
);
})}
</ul>
return (
<li key={qrType.id}>
{usable ? (
<Link
href={`/erstellen/${qrType.id}`}
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
>
{tile}
</Link>
) : (
<div
aria-disabled="true"
title={`„${qrType.title}“ folgt in einer späteren Version`}
className="flex h-full cursor-not-allowed items-start gap-4 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-4"
>
{tile}
</div>
)}
</li>
);
})}
</ul>
)}
<p className="mt-8 text-sm text-slate-500">
In Version 1 ist der Typ „Website“ verfügbar. Die übrigen Typen folgen in
Version 2.
</p>
{usableCount > 0 && usableCount < types.length ? (
<p className="mt-8 text-sm text-slate-500">
Die übrigen Typen folgen in einer späteren Version. Welche Kacheln
hier erscheinen, legst du unter{" "}
<Link
href="/verwaltung/typen"
className="font-medium text-slate-600 underline"
>
Verwaltung → QR-Typen
</Link>{" "}
fest.
</p>
) : null}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import { TypeSettings, type TypeSetting } from "@/components/type-settings";
import { getQrTypeAvailability } from "@/lib/qr/availability";
export const metadata: Metadata = { title: "QR-Typen" };
export const dynamic = "force-dynamic";
export default async function TypeSettingsPage() {
const availability = await getQrTypeAvailability();
const initial: TypeSetting[] = availability.map(
({ qrType, implemented, enabled }) => ({
id: qrType.id,
title: qrType.title,
description: qrType.description,
icon: qrType.icon,
implemented,
enabled,
}),
);
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Zur Typ-Auswahl
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
QR-Typen
</h1>
<p className="mt-2 text-slate-600">
Lege fest, welche Typen auf der Startseite erscheinen. Abgeschaltete
Typen sind komplett ausgeblendet und lassen sich nicht mehr neu
anlegen.
</p>
<p className="mt-2 text-sm text-slate-500">
Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie
leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code
wird durch das Abschalten seines Typs also nicht ungültig.
</p>
</div>
<TypeSettings initial={initial} />
</div>
);
}