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:
@@ -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 { 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);
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
return qrType?.active ? qrType : undefined;
|
||||
return qrType?.implemented ? qrType : undefined;
|
||||
}
|
||||
|
||||
export { QR_TYPE_IDS, isQrTypeId };
|
||||
|
||||
+15
-19
@@ -1,8 +1,14 @@
|
||||
import "server-only";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireAvailableQrType } from "@/lib/qr/availability";
|
||||
import { designSchema } from "@/lib/qr/design";
|
||||
import { getActiveQrType } from "@/lib/qr/registry";
|
||||
import { QR_TYPE_IDS, type QrTypeModule } from "@/lib/qr/type-module";
|
||||
import {
|
||||
QR_TYPE_IDS,
|
||||
QrRequestError,
|
||||
type QrTypeModule,
|
||||
} from "@/lib/qr/type-module";
|
||||
import { shortUrl } from "@/lib/env";
|
||||
import { isValidSlug } from "@/lib/slug";
|
||||
|
||||
@@ -30,15 +36,9 @@ export const qrRequestSchema = z.object({
|
||||
|
||||
export type QrRequest = z.infer<typeof qrRequestSchema>;
|
||||
|
||||
/** Fehler mit einer Meldung, die unverändert an die Oberfläche geht. */
|
||||
export class QrRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number = 400,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
// Liegt in type-module.ts, damit auch availability.ts ihn werfen kann, ohne
|
||||
// dass ein Ringschluss zwischen den beiden Modulen entsteht.
|
||||
export { QrRequestError };
|
||||
|
||||
export type ResolvedQrRequest = {
|
||||
qrType: QrTypeModule;
|
||||
@@ -51,14 +51,10 @@ export type ResolvedQrRequest = {
|
||||
* Prüft eine Anfrage vollständig und liefert den zu kodierenden Inhalt:
|
||||
* bei statischen Codes die Nutzdaten selbst, bei dynamischen die Kurz-URL.
|
||||
*/
|
||||
export function resolveQrRequest(request: QrRequest): ResolvedQrRequest {
|
||||
const qrType = getActiveQrType(request.type);
|
||||
if (!qrType) {
|
||||
throw new QrRequestError(
|
||||
"Dieser QR-Typ ist in dieser Version noch nicht verfügbar.",
|
||||
404,
|
||||
);
|
||||
}
|
||||
export async function resolveQrRequest(
|
||||
request: QrRequest,
|
||||
): Promise<ResolvedQrRequest> {
|
||||
const qrType = await requireAvailableQrType(request.type);
|
||||
|
||||
const validated = qrType.validate(request.data);
|
||||
if (!validated.ok) {
|
||||
|
||||
@@ -46,6 +46,19 @@ export type ValidationResult =
|
||||
*/
|
||||
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
|
||||
* arbeitet. Die konkreten Datentypen sind hier bewusst gelöscht: die
|
||||
@@ -57,8 +70,14 @@ export interface QrTypeModule {
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
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)? */
|
||||
readonly supportsDynamic: boolean;
|
||||
/** Startwerte für ein leeres Formular. */
|
||||
@@ -78,7 +97,7 @@ type QrTypeSpec<TSchema extends z.ZodType> = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: IconName;
|
||||
active: boolean;
|
||||
implemented: boolean;
|
||||
supportsDynamic: boolean;
|
||||
schema: TSchema;
|
||||
defaultData: QrFormData;
|
||||
@@ -120,7 +139,7 @@ export function defineQrType<TSchema extends z.ZodType>(
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
icon: spec.icon,
|
||||
active: spec.active,
|
||||
implemented: spec.implemented,
|
||||
supportsDynamic: spec.supportsDynamic,
|
||||
defaultData: spec.defaultData,
|
||||
validate(input) {
|
||||
@@ -157,7 +176,7 @@ export function defineComingSoonType(spec: {
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
icon: spec.icon,
|
||||
active: false,
|
||||
implemented: false,
|
||||
supportsDynamic: false,
|
||||
defaultData: {},
|
||||
validate: () => ({
|
||||
|
||||
@@ -47,7 +47,7 @@ export const websiteType = defineQrType({
|
||||
title: "Website",
|
||||
description: "Verlinke eine beliebige Webadresse.",
|
||||
icon: "globe",
|
||||
active: true,
|
||||
implemented: true,
|
||||
supportsDynamic: true,
|
||||
schema: websiteDataSchema,
|
||||
defaultData: { url: "" },
|
||||
|
||||
Reference in New Issue
Block a user