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:
@@ -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")
|
||||||
|
);
|
||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.`,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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: () => ({
|
||||||
|
|||||||
@@ -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: "" },
|
||||||
|
|||||||
Reference in New Issue
Block a user