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
+29 -1
View File
@@ -176,7 +176,33 @@ exakt das, was auch heruntergeladen wird.
**PNG** für Druck und Office-Dokumente, **SVG** für verlustfreie Skalierung **PNG** für Druck und Office-Dokumente, **SVG** für verlustfreie Skalierung
(Plakate, Schilder, Druckvorstufe). (Plakate, Schilder, Druckvorstufe).
### 5. Meine Codes ### 5. Verwaltung: welche Typen erscheinen
Unter **Verwaltung → QR-Typen** lässt sich jeder der 15 Typen einzeln
ein- und ausblenden. Ausgeblendete Typen verschwinden vollständig von der
Startseite und lassen sich nicht mehr neu anlegen — praktisch, um die noch
nicht umgesetzten Typen gar nicht erst zu zeigen.
Zwei Eigenschaften sind dabei zu unterscheiden:
| | Bedeutung | Woher |
| --- | --- | --- |
| **umgesetzt** | Der Typ ist ausprogrammiert. In Version 1 gilt das nur für „Website“. | Registry im Code |
| **freigegeben** | Die Verwaltung zeigt den Typ an. | Datenbank, jederzeit änderbar |
Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht
umgesetzt, erscheint als Kachel „Bald verfügbar“.
> **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab,
> leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter
> bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also
> nicht ungültig — gesperrt ist nur das Anlegen neuer Codes.
Ohne Eintrag in der Datenbank gilt ein Typ als freigegeben. Eine frische
Installation zeigt also alles, und Typen aus späteren Versionen erscheinen
automatisch.
### 6. Meine Codes
Unter „Meine Codes“ stehen alle dynamischen Codes mit Vorschaubild, Unter „Meine Codes“ stehen alle dynamischen Codes mit Vorschaubild,
Kurz-URL, aktuellem Ziel, Scan-Anzahl, Erstellungsdatum und letztem Scan. Kurz-URL, aktuellem Ziel, Scan-Anzahl, Erstellungsdatum und letztem Scan.
@@ -340,6 +366,8 @@ deutschen Meldung.
| `DELETE` | `/api/codes/{id}` | Code löschen | | `DELETE` | `/api/codes/{id}` | Code löschen |
| `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) | | `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) |
| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler | | `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler |
| `GET` | `/api/admin/types` | Zustand aller QR-Typen |
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten |
Beispiel: Beispiel:
@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "QrTypeSetting" (
"type" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "QrTypeSetting_pkey" PRIMARY KEY ("type")
);
+13
View File
@@ -10,6 +10,19 @@ datasource db {
provider = "postgresql" provider = "postgresql"
} }
/// Freigabe eines QR-Typs durch die Verwaltung.
///
/// Es gibt nur Zeilen für Typen, die bewusst umgeschaltet wurden — fehlt ein
/// Eintrag, gilt der Typ als freigegeben. So bleibt eine frische Installation
/// ohne Vorbelegung nutzbar, und neue Typen aus späteren Versionen sind
/// automatisch sichtbar.
model QrTypeSetting {
/// Kennung aus der Typ-Registry, z. B. "website".
type String @id
enabled Boolean
updatedAt DateTime @updatedAt
}
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und /// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird. /// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
/// ///
+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 { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireImplementedQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design"; import { designSchema } from "@/lib/qr/design";
import { getActiveQrType } from "@/lib/qr/registry"; import { QrRequestError } from "@/lib/qr/type-module";
import { QrRequestError } from "@/lib/qr/request";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -60,12 +60,10 @@ export async function PATCH(
const update: Prisma.QrCodeUpdateInput = {}; const update: Prisma.QrCodeUpdateInput = {};
if (parsed.data.data !== undefined) { if (parsed.data.data !== undefined) {
const qrType = getActiveQrType(existing.type); // Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
if (!qrType) { // Verwaltung abgeschalteter Typ soll bestehende Codes nicht
throw new QrRequestError( // unbearbeitbar machen — sie sind längst gedruckt.
"Der Typ dieses Codes wird in dieser Version nicht unterstützt.", const qrType = requireImplementedQrType(existing.type);
);
}
const validated = qrType.validate(parsed.data.data); const validated = qrType.validate(parsed.data.data);
if (!validated.ok) { if (!validated.ok) {
return jsonError(validated.message); return jsonError(validated.message);
+3 -10
View File
@@ -4,10 +4,9 @@ import { z } from "zod";
import { toJsonInput, toQrCodeDto } from "@/lib/codes"; import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireAvailableQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design"; import { designSchema } from "@/lib/qr/design";
import { getActiveQrType } from "@/lib/qr/registry"; import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
import { QrRequestError } from "@/lib/qr/request";
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
import { generateSlug } from "@/lib/slug"; import { generateSlug } from "@/lib/slug";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -51,13 +50,7 @@ export async function POST(request: NextRequest): Promise<Response> {
} }
const { type, design, label } = parsed.data; const { type, design, label } = parsed.data;
const qrType = getActiveQrType(type); const qrType = await requireAvailableQrType(type);
if (!qrType) {
throw new QrRequestError(
"Dieser QR-Typ ist in dieser Version noch nicht verfügbar.",
404,
);
}
if (!qrType.supportsDynamic) { if (!qrType.supportsDynamic) {
throw new QrRequestError( throw new QrRequestError(
`Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`, `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 = const body =
format === "png" format === "png"
+1 -1
View File
@@ -15,7 +15,7 @@ export async function POST(request: NextRequest): Promise<Response> {
return jsonError(firstZodMessage(parsed.error)); return jsonError(firstZodMessage(parsed.error));
} }
const { content } = resolveQrRequest(parsed.data); const { content } = await resolveQrRequest(parsed.data);
const svg = await renderSvg(content, parsed.data.design); const svg = await renderSvg(content, parsed.data.design);
return Response.json({ return Response.json({
+48 -27
View File
@@ -4,14 +4,13 @@ import { notFound } from "next/navigation";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator"; 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 }> }; type Props = { params: Promise<{ type: string }> };
/** Alle bekannten Typen vorab erzeugen — auch die noch inaktiven. */ // Die Freigabe der Typen kommt aus der Datenbank und ist jederzeit änderbar.
export function generateStaticParams() { export const dynamic = "force-dynamic";
return QR_TYPE_IDS.map((type) => ({ type }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> { export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { type } = await params; 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" }; return { title: qrType ? `${qrType.title}-QR-Code` : "QR-Code erstellen" };
} }
export default async function CreatePage({ params }: Props) { /** Erklärseite statt 404 — der Typ existiert, ist nur (noch) nicht nutzbar. */
const { type } = await params; function Hinweis({
const qrType = getQrType(type); icon,
titel,
if (!qrType) { text,
notFound(); }: {
} icon: Parameters<typeof Icon>[0]["name"];
titel: string;
// Registrierte, aber noch nicht implementierte Typen bekommen eine text: string;
// erklärende Seite statt einer 404. }) {
if (!getActiveQrType(type)) {
return ( return (
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6"> <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"> <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" /> <Icon name={icon} className="size-7" />
</span> </span>
<h1 className="mt-5 text-2xl font-bold text-slate-900"> <h1 className="mt-5 text-2xl font-bold text-slate-900">{titel}</h1>
„{qrType.title}“ ist bald verfügbar <p className="mt-2 text-slate-600">{text}</p>
</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 <Link
href="/erstellen/website" 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" 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 Zur Typ-Auswahl
</Link> </Link>
</div> </div>
); );
} }
return <QrGenerator typeId={qrType.id} />; export default async function CreatePage({ params }: Props) {
const { type } = await params;
const state = await getQrTypeState(type);
if (!state) {
notFound();
}
if (!state.enabled) {
return (
<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."
/>
);
}
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"> <html lang="de" className="h-full">
<body className="flex min-h-full flex-col"> <body className="flex min-h-full flex-col">
<header className="border-b border-slate-200 bg-white"> <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 <Link
href="/" href="/"
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900" 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"> <span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
<Icon name="qr" className="size-5" /> <Icon name="qr" className="size-5" />
</span> </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> </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 <Link
href="/" href="/"
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900" 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 Meine Codes
</Link> </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> </nav>
</div> </div>
</header> </header>
+50 -17
View File
@@ -1,10 +1,17 @@
import Link from "next/link"; import Link from "next/link";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { Badge } from "@/components/ui"; import { Badge, Notice } from "@/components/ui";
import { qrTypeList } from "@/lib/qr/registry"; 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 ( return (
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14"> <div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<div className="max-w-2xl"> <div className="max-w-2xl">
@@ -18,31 +25,47 @@ export default function HomePage() {
</p> </p>
</div> </div>
{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"> <ul className="mt-9 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{qrTypeList.map((type) => { {types.map(({ qrType, usable }) => {
const tile = ( const tile = (
<> <>
<span <span
className={ className={
type.active 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-indigo-50 text-indigo-600"
: "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400" : "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
} }
> >
<Icon name={type.icon} className="size-6" /> <Icon name={qrType.icon} className="size-6" />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2"> <span className="flex flex-wrap items-center gap-2">
<span <span
className={ className={
type.active usable
? "font-semibold text-slate-900" ? "font-semibold text-slate-900"
: "font-semibold text-slate-500" : "font-semibold text-slate-500"
} }
> >
{type.title} {qrType.title}
</span> </span>
{type.active ? ( {usable ? (
<Badge tone="popular">Beliebt</Badge> <Badge tone="popular">Beliebt</Badge>
) : ( ) : (
<Badge tone="soon">Bald verfügbar</Badge> <Badge tone="soon">Bald verfügbar</Badge>
@@ -50,22 +73,22 @@ export default function HomePage() {
</span> </span>
<span <span
className={ className={
type.active usable
? "mt-1 block text-sm text-slate-600" ? "mt-1 block text-sm text-slate-600"
: "mt-1 block text-sm text-slate-400" : "mt-1 block text-sm text-slate-400"
} }
> >
{type.description} {qrType.description}
</span> </span>
</span> </span>
</> </>
); );
return ( return (
<li key={type.id}> <li key={qrType.id}>
{type.active ? ( {usable ? (
<Link <Link
href={`/erstellen/${type.id}`} 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" 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} {tile}
@@ -73,7 +96,7 @@ export default function HomePage() {
) : ( ) : (
<div <div
aria-disabled="true" aria-disabled="true"
title={`„${type.title}“ folgt in Version 2`} 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" 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} {tile}
@@ -83,11 +106,21 @@ export default function HomePage() {
); );
})} })}
</ul> </ul>
)}
{usableCount > 0 && usableCount < types.length ? (
<p className="mt-8 text-sm text-slate-500"> <p className="mt-8 text-sm text-slate-500">
In Version 1 ist der Typ „Website“ verfügbar. Die übrigen Typen folgen in Die übrigen Typen folgen in einer späteren Version. Welche Kacheln
Version 2. hier erscheinen, legst du unter{" "}
<Link
href="/verwaltung/typen"
className="font-medium text-slate-600 underline"
>
Verwaltung → QR-Typen
</Link>{" "}
fest.
</p> </p>
) : null}
</div> </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>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useState } from "react";
import { Icon, type IconName } from "@/components/icons";
import { Badge, Card, Notice, cx } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
import type { QrTypeId } from "@/lib/qr/type-module";
export type TypeSetting = {
id: QrTypeId;
title: string;
description: string;
icon: IconName;
implemented: boolean;
enabled: boolean;
};
function Toggle({
checked,
disabled,
label,
onChange,
}: {
checked: boolean;
disabled: boolean;
label: string;
onChange: (next: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cx(
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-indigo-600" : "bg-slate-300",
)}
>
<span
className={cx(
"inline-block size-5 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-5.5" : "translate-x-0.5",
)}
/>
</button>
);
}
export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
const [types, setTypes] = useState(initial);
const [pending, setPending] = useState<QrTypeId | null>(null);
const [error, setError] = useState<string | null>(null);
const visibleCount = types.filter((entry) => entry.enabled).length;
const usableCount = types.filter(
(entry) => entry.enabled && entry.implemented,
).length;
const toggle = async (id: QrTypeId, enabled: boolean) => {
const before = types;
// Sofort umschalten und bei einem Fehler zurückdrehen — der Schalter
// soll sich nicht zäh anfühlen.
setTypes((current) =>
current.map((entry) => (entry.id === id ? { ...entry, enabled } : entry)),
);
setPending(id);
setError(null);
try {
await apiJson("/api/admin/types", {
method: "PATCH",
json: { type: id, enabled },
});
} catch (caught) {
setTypes(before);
setError(
caught instanceof ApiError
? caught.message
: "Die Änderung konnte nicht gespeichert werden.",
);
} finally {
setPending(null);
}
};
return (
<div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null}
{visibleCount === 0 ? (
<Notice tone="warning">
Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es
lassen sich keine neuen Codes anlegen. Bereits gespeicherte
dynamische Codes leiten weiterhin ganz normal weiter.
</Notice>
) : usableCount === 0 ? (
<Notice tone="warning">
Es ist kein nutzbarer Typ freigegeben. Die freigegebenen Typen sind in
dieser Version noch nicht ausprogrammiert.
</Notice>
) : null}
<Card className="!p-0">
<ul className="divide-y divide-slate-200">
{types.map((entry) => (
<li
key={entry.id}
className="flex items-start gap-4 p-4 sm:items-center sm:px-5"
>
<span
className={cx(
"flex size-10 shrink-0 items-center justify-center rounded-xl",
entry.enabled && entry.implemented
? "bg-indigo-50 text-indigo-600"
: "bg-slate-100 text-slate-400",
)}
>
<Icon name={entry.icon} className="size-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span
className={cx(
"font-medium",
entry.enabled ? "text-slate-900" : "text-slate-500",
)}
>
{entry.title}
</span>
{entry.implemented ? (
<Badge tone="success">verfügbar</Badge>
) : (
<Badge tone="soon">noch nicht umgesetzt</Badge>
)}
</div>
<p className="mt-0.5 text-sm text-slate-500">
{entry.enabled
? entry.implemented
? "Erscheint auf der Startseite und kann verwendet werden."
: "Erscheint auf der Startseite als „Bald verfügbar“."
: "Ausgeblendet — erscheint nicht auf der Startseite."}
</p>
</div>
<Toggle
checked={entry.enabled}
disabled={pending === entry.id}
label={`${entry.title} ${entry.enabled ? "ausblenden" : "freigeben"}`}
onChange={(next) => void toggle(entry.id, next)}
/>
</li>
))}
</ul>
</Card>
<p className="text-sm text-slate-500">
{usableCount} von {types.length} Typen nutzbar, {visibleCount} auf der
Startseite sichtbar.
</p>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
import "server-only";
import { prisma } from "@/lib/db";
import { getQrType, qrTypeList } from "@/lib/qr/registry";
import {
isQrTypeId,
QrRequestError,
type QrTypeId,
type QrTypeModule,
} from "@/lib/qr/type-module";
/**
* Freigabe der QR-Typen durch die Verwaltung.
*
* Zwei voneinander unabhängige Eigenschaften bestimmen, was ein Typ kann:
*
* - `implemented` — steht in der Registry und sagt, ob der Typ überhaupt
* ausprogrammiert ist. In V1 gilt das nur für „Website“.
* - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ
* freigegeben hat. Fehlt der Eintrag, ist der Typ freigegeben.
*
* Ein abgeschalteter Typ verschwindet aus der Oberfläche und lässt sich nicht
* mehr neu anlegen. **Bereits bestehende Codes dieses Typs funktionieren
* weiter** — sie sind gedruckt und verteilt, und ein Schalter in der
* Verwaltung darf sie nicht ins Leere laufen lassen.
*/
export type QrTypeAvailability = {
qrType: QrTypeModule;
/** Der Typ ist in dieser Version ausprogrammiert. */
implemented: boolean;
/** Die Verwaltung hat den Typ freigegeben. */
enabled: boolean;
/** Freigegeben und ausprogrammiert — nur dann ist er benutzbar. */
usable: boolean;
/** Freigegeben, aber noch nicht ausprogrammiert — „Bald verfügbar“. */
announced: boolean;
};
/** Abschaltungen aus der Datenbank; alles nicht Genannte ist freigegeben. */
async function loadDisabledTypes(): Promise<ReadonlySet<string>> {
try {
const rows = await prisma.qrTypeSetting.findMany({
where: { enabled: false },
select: { type: true },
});
return new Set(rows.map((row) => row.type));
} catch (error) {
// Ohne Datenbank ist die Anwendung für statische Codes weiterhin
// brauchbar. Lieber alles anzeigen als eine leere Startseite.
console.error(
"Freigaben der QR-Typen konnten nicht geladen werden — es gilt die Voreinstellung (alle freigegeben):",
error,
);
return new Set();
}
}
/** Verfügbarkeit aller Typen in Anzeigereihenfolge. */
export async function getQrTypeAvailability(): Promise<QrTypeAvailability[]> {
const disabled = await loadDisabledTypes();
return qrTypeList.map((qrType) => {
const enabled = !disabled.has(qrType.id);
return {
qrType,
implemented: qrType.implemented,
enabled,
usable: enabled && qrType.implemented,
announced: enabled && !qrType.implemented,
};
});
}
/** Nur die Typen, die in der Oberfläche erscheinen sollen. */
export async function getVisibleQrTypes(): Promise<QrTypeAvailability[]> {
return (await getQrTypeAvailability()).filter((entry) => entry.enabled);
}
export async function getQrTypeState(
id: string,
): Promise<QrTypeAvailability | undefined> {
if (!isQrTypeId(id)) return undefined;
return (await getQrTypeAvailability()).find((entry) => entry.qrType.id === id);
}
/**
* Liefert den Typ für das Anlegen neuer Codes — oder wirft mit einer
* Meldung, die unverändert in die Oberfläche gehen kann.
*/
export async function requireAvailableQrType(
id: string,
): Promise<QrTypeModule> {
const state = await getQrTypeState(id);
if (!state) {
throw new QrRequestError("Unbekannter QR-Typ.", 404);
}
if (!state.implemented) {
throw new QrRequestError(
`Der QR-Typ „${state.qrType.title}“ ist in dieser Version noch nicht verfügbar.`,
404,
);
}
if (!state.enabled) {
throw new QrRequestError(
`Der QR-Typ „${state.qrType.title}“ ist in der Verwaltung abgeschaltet.`,
403,
);
}
return state.qrType;
}
/**
* Typ eines bestehenden Codes. Hier zählt nur, ob der Typ ausprogrammiert
* ist — eine Abschaltung in der Verwaltung darf bestehende Codes nicht
* unbearbeitbar machen.
*/
export function requireImplementedQrType(id: string): QrTypeModule {
const qrType = getQrType(id);
if (!qrType?.implemented) {
throw new QrRequestError(
"Der Typ dieses Codes wird in dieser Version nicht unterstützt.",
404,
);
}
return qrType;
}
/** Freigabe eines Typs setzen. */
export async function setQrTypeEnabled(
id: QrTypeId,
enabled: boolean,
): Promise<void> {
await prisma.qrTypeSetting.upsert({
where: { type: id },
create: { type: id, enabled },
update: { enabled },
});
}
+9 -3
View File
@@ -120,10 +120,16 @@ export function getQrType(id: string): QrTypeModule | undefined {
return isQrTypeId(id) ? qrTypes[id] : undefined; return isQrTypeId(id) ? qrTypes[id] : undefined;
} }
/** Liefert den Typ nur, wenn er in dieser Version nutzbar ist. */ /**
export function getActiveQrType(id: string): QrTypeModule | undefined { * Liefert den Typ nur, wenn er in dieser Version ausprogrammiert ist.
*
* Achtung: das ist noch keine Freigabe. Ob ein Typ neu angelegt werden darf,
* entscheidet zusätzlich die Verwaltung — dafür `requireAvailableQrType` aus
* `src/lib/qr/availability.ts` verwenden.
*/
export function getImplementedQrType(id: string): QrTypeModule | undefined {
const qrType = getQrType(id); const qrType = getQrType(id);
return qrType?.active ? qrType : undefined; return qrType?.implemented ? qrType : undefined;
} }
export { QR_TYPE_IDS, isQrTypeId }; export { QR_TYPE_IDS, isQrTypeId };
+15 -19
View File
@@ -1,8 +1,14 @@
import "server-only";
import { z } from "zod"; import { z } from "zod";
import { requireAvailableQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design"; import { designSchema } from "@/lib/qr/design";
import { getActiveQrType } from "@/lib/qr/registry"; import {
import { QR_TYPE_IDS, type QrTypeModule } from "@/lib/qr/type-module"; QR_TYPE_IDS,
QrRequestError,
type QrTypeModule,
} from "@/lib/qr/type-module";
import { shortUrl } from "@/lib/env"; import { shortUrl } from "@/lib/env";
import { isValidSlug } from "@/lib/slug"; import { isValidSlug } from "@/lib/slug";
@@ -30,15 +36,9 @@ export const qrRequestSchema = z.object({
export type QrRequest = z.infer<typeof qrRequestSchema>; export type QrRequest = z.infer<typeof qrRequestSchema>;
/** Fehler mit einer Meldung, die unverändert an die Oberfläche geht. */ // Liegt in type-module.ts, damit auch availability.ts ihn werfen kann, ohne
export class QrRequestError extends Error { // dass ein Ringschluss zwischen den beiden Modulen entsteht.
constructor( export { QrRequestError };
message: string,
readonly status: number = 400,
) {
super(message);
}
}
export type ResolvedQrRequest = { export type ResolvedQrRequest = {
qrType: QrTypeModule; qrType: QrTypeModule;
@@ -51,14 +51,10 @@ export type ResolvedQrRequest = {
* Prüft eine Anfrage vollständig und liefert den zu kodierenden Inhalt: * Prüft eine Anfrage vollständig und liefert den zu kodierenden Inhalt:
* bei statischen Codes die Nutzdaten selbst, bei dynamischen die Kurz-URL. * bei statischen Codes die Nutzdaten selbst, bei dynamischen die Kurz-URL.
*/ */
export function resolveQrRequest(request: QrRequest): ResolvedQrRequest { export async function resolveQrRequest(
const qrType = getActiveQrType(request.type); request: QrRequest,
if (!qrType) { ): Promise<ResolvedQrRequest> {
throw new QrRequestError( const qrType = await requireAvailableQrType(request.type);
"Dieser QR-Typ ist in dieser Version noch nicht verfügbar.",
404,
);
}
const validated = qrType.validate(request.data); const validated = qrType.validate(request.data);
if (!validated.ok) { if (!validated.ok) {
+24 -5
View File
@@ -46,6 +46,19 @@ export type ValidationResult =
*/ */
export class QrTypeError extends Error {} export class QrTypeError extends Error {}
/**
* Fehler mit einer Meldung, die unverändert an die Oberfläche geht, plus
* dem passenden HTTP-Status.
*/
export class QrRequestError extends Error {
constructor(
message: string,
readonly status: number = 400,
) {
super(message);
}
}
/** /**
* Die Schnittstelle, über die der Rest der Anwendung mit einem QR-Typ * Die Schnittstelle, über die der Rest der Anwendung mit einem QR-Typ
* arbeitet. Die konkreten Datentypen sind hier bewusst gelöscht: die * arbeitet. Die konkreten Datentypen sind hier bewusst gelöscht: die
@@ -57,8 +70,14 @@ export interface QrTypeModule {
readonly title: string; readonly title: string;
readonly description: string; readonly description: string;
readonly icon: IconName; readonly icon: IconName;
/** Ist der Typ in dieser Version nutzbar? */ /**
readonly active: boolean; * Ist der Typ in dieser Version ausprogrammiert?
*
* Nicht zu verwechseln mit der Freigabe: ob ein Typ in der Oberfläche
* erscheint, entscheidet zusätzlich die Verwaltung — siehe
* `src/lib/qr/availability.ts`.
*/
readonly implemented: boolean;
/** Unterstützt der Typ dynamische Codes (Kurz-URL + Weiterleitung)? */ /** Unterstützt der Typ dynamische Codes (Kurz-URL + Weiterleitung)? */
readonly supportsDynamic: boolean; readonly supportsDynamic: boolean;
/** Startwerte für ein leeres Formular. */ /** Startwerte für ein leeres Formular. */
@@ -78,7 +97,7 @@ type QrTypeSpec<TSchema extends z.ZodType> = {
title: string; title: string;
description: string; description: string;
icon: IconName; icon: IconName;
active: boolean; implemented: boolean;
supportsDynamic: boolean; supportsDynamic: boolean;
schema: TSchema; schema: TSchema;
defaultData: QrFormData; defaultData: QrFormData;
@@ -120,7 +139,7 @@ export function defineQrType<TSchema extends z.ZodType>(
title: spec.title, title: spec.title,
description: spec.description, description: spec.description,
icon: spec.icon, icon: spec.icon,
active: spec.active, implemented: spec.implemented,
supportsDynamic: spec.supportsDynamic, supportsDynamic: spec.supportsDynamic,
defaultData: spec.defaultData, defaultData: spec.defaultData,
validate(input) { validate(input) {
@@ -157,7 +176,7 @@ export function defineComingSoonType(spec: {
title: spec.title, title: spec.title,
description: spec.description, description: spec.description,
icon: spec.icon, icon: spec.icon,
active: false, implemented: false,
supportsDynamic: false, supportsDynamic: false,
defaultData: {}, defaultData: {},
validate: () => ({ validate: () => ({
+1 -1
View File
@@ -47,7 +47,7 @@ export const websiteType = defineQrType({
title: "Website", title: "Website",
description: "Verlinke eine beliebige Webadresse.", description: "Verlinke eine beliebige Webadresse.",
icon: "globe", icon: "globe",
active: true, implemented: true,
supportsDynamic: true, supportsDynamic: true,
schema: websiteDataSchema, schema: websiteDataSchema,
defaultData: { url: "" }, defaultData: { url: "" },