Files
qrcode-generator/src/components/domain-admin.tsx
T
Florian Wilde 1064915d37 Startseite: „Beliebt" ersetzt durch „nur dynamisch"
Das Abzeichen stammte aus Version 1, als genau ein Typ nutzbar war. Seit alle
15 gebaut sind, stand es an jeder Kachel und sagte damit nichts.

An seiner Stelle steht jetzt nur noch dort etwas, wo es die Wahl beeinflusst:
sechs Typen brauchen zwingend eine Kurz-URL und funktionieren deshalb nicht
ohne diesen Server. Die uebrigen neun Kacheln bleiben unbeschriftet.

Die Badge-Variante hiess "popular" und wird an drei weiteren Stellen als
Hervorhebung benutzt — sie heisst jetzt "accent", weil sie mit Beliebtheit
nichts zu tun hat.

Dazu die Roadmap neu geordnet: Typen je Gruppe, Statistik, Papierkorb, Logo
im Code, Ablaufdatum — in dieser Reihenfolge, jeweils mit dem, was dabei zu
entscheiden ist.
2026-08-15 07:39:20 +02:00

388 lines
13 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import {
Badge,
Button,
Card,
Checkbox,
Field,
Notice,
TextInput,
} from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
type Grant = { groupId: string; groupName: string; isDefault: boolean };
type AdminDomain = {
id: string;
hostname: string;
label: string | null;
isActive: boolean;
codeCount: number;
grants: Grant[];
};
type GroupOption = { id: string; name: string };
export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
const [domains, setDomains] = useState<AdminDomain[] | null>(null);
const [groups, setGroups] = useState<GroupOption[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({ hostname: "", label: "" });
const [formError, setFormError] = useState<string | null>(null);
const [editing, setEditing] = useState<AdminDomain | null>(null);
const [grants, setGrants] = useState<Grant[]>([]);
const [grantError, setGrantError] = useState<string | null>(null);
const [deleting, setDeleting] = useState<AdminDomain | null>(null);
const load = useCallback(async () => {
try {
const result = await apiJson<{
domains: AdminDomain[];
groups: GroupOption[];
}>("/api/admin/domains");
setDomains(result.domains);
setGroups(result.groups);
setError(null);
} catch (caught) {
setDomains([]);
setError(
caught instanceof ApiError
? caught.message
: "Die Domains konnten nicht geladen werden.",
);
}
}, []);
useEffect(() => {
// setState folgt erst nach dem await der Netzwerkantwort.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
const create = async () => {
setFormError(null);
try {
await apiJson("/api/admin/domains", { method: "POST", json: form });
setCreating(false);
setForm({ hostname: "", label: "" });
await load();
} catch (caught) {
setFormError(
caught instanceof ApiError
? caught.message
: "Die Domain konnte nicht angelegt werden.",
);
}
};
const patch = async (id: string, body: unknown, onDone?: () => void) => {
setBusy(id);
setError(null);
try {
await apiJson(`/api/admin/domains/${id}`, { method: "PATCH", json: body });
await load();
onDone?.();
return true;
} catch (caught) {
const message =
caught instanceof ApiError
? caught.message
: "Die Änderung konnte nicht gespeichert werden.";
if (onDone) setGrantError(message);
else setError(message);
return false;
} finally {
setBusy(null);
}
};
const remove = async () => {
if (!deleting) return;
try {
await apiJson(`/api/admin/domains/${deleting.id}`, { method: "DELETE" });
setDeleting(null);
await load();
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Domain konnte nicht gelöscht werden.",
);
setDeleting(null);
}
};
if (domains === null) {
return <p className="text-sm text-slate-500">Domains werden geladen …</p>;
}
return (
<div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null}
<Notice tone="info">
Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung
weiterleitet. Die Weiterleitung erkennt sie am{" "}
<code className="font-mono text-xs">Host</code>-Header und prüft ihn
gegen diese Liste — ein unbekannter Hostname endet mit 404.
</Notice>
<div className="flex justify-end">
<Button type="button" onClick={() => setCreating(true)}>
Domain registrieren
</Button>
</div>
{domains.map((domain) => (
<Card key={domain.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-mono font-semibold text-slate-900">
{domain.hostname}
</h2>
{domain.hostname === baseUrlHostname ? (
<Badge tone="accent">aus BASE_URL</Badge>
) : null}
{!domain.isActive ? (
<Badge tone="soon">abgeschaltet</Badge>
) : null}
<Badge tone="neutral">
{domain.codeCount} {domain.codeCount === 1 ? "Code" : "Codes"}
</Badge>
</div>
{domain.label ? (
<p className="mt-1 text-sm text-slate-600">{domain.label}</p>
) : null}
<p className="mt-2 text-sm text-slate-600">
{domain.grants.length === 0
? "Keiner Gruppe zugeteilt — niemand kann hier Codes anlegen."
: domain.grants
.map(
(grant) =>
`${grant.groupName}${grant.isDefault ? " (Voreinstellung)" : ""}`,
)
.join(" · ")}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === domain.id}
onClick={() => {
setEditing(domain);
setGrants(domain.grants);
setGrantError(null);
}}
>
<Icon name="pencil" className="size-3.5" />
Gruppen zuteilen
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === domain.id}
onClick={() => void patch(domain.id, { isActive: !domain.isActive })}
>
{domain.isActive ? "Abschalten" : "Einschalten"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
disabled={busy === domain.id}
onClick={() => setDeleting(domain)}
>
<Icon name="trash" className="size-3.5" />
Löschen
</Button>
</div>
</div>
</div>
</Card>
))}
{/* --------------------------------------------------- Registrieren */}
<Modal
open={creating}
title="Domain registrieren"
onClose={() => setCreating(false)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setCreating(false)}>
Abbrechen
</Button>
<Button type="button" onClick={() => void create()}>
Registrieren
</Button>
</>
}
>
<div className="space-y-4">
{formError ? <Notice tone="error">{formError}</Notice> : null}
<Field
label="Hostname"
htmlFor="new-domain-host"
hint="Ohne https:// und ohne Pfad — genau so, wie er im Browser steht."
>
<TextInput
id="new-domain-host"
className="font-mono"
placeholder="qr.example.com"
value={form.hostname}
onChange={(event) =>
setForm({ ...form, hostname: event.target.value })
}
/>
</Field>
<Field label="Bezeichnung (optional)" htmlFor="new-domain-label">
<TextInput
id="new-domain-label"
placeholder="Kundenprojekt Nord"
value={form.label}
onChange={(event) => setForm({ ...form, label: event.target.value })}
/>
</Field>
</div>
</Modal>
{/* ------------------------------------------------------- Zuteilung */}
<Modal
open={editing !== null}
title={`Gruppen für ${editing?.hostname ?? ""}`}
onClose={() => setEditing(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setEditing(null)}>
Abbrechen
</Button>
<Button
type="button"
disabled={busy === editing?.id}
onClick={() =>
editing &&
void patch(
editing.id,
{
grants: grants.map((grant) => ({
groupId: grant.groupId,
isDefault: grant.isDefault,
})),
},
() => setEditing(null),
)
}
>
Speichern
</Button>
</>
}
>
<div className="space-y-4">
{grantError ? <Notice tone="error">{grantError}</Notice> : null}
<p className="text-sm text-slate-600">
Mehrere Gruppen dürfen dieselbe Domain nutzen. Die Voreinstellung
bestimmt, welche Domain beim Anlegen eines Codes vorausgewählt ist.
</p>
<ul className="space-y-2">
{groups.map((group) => {
const grant = grants.find((entry) => entry.groupId === group.id);
return (
<li
key={group.id}
className="flex flex-wrap items-center gap-3 rounded-lg border border-slate-200 p-2.5"
>
<Checkbox
id={`grant-${group.id}`}
label={group.name}
checked={Boolean(grant)}
onChange={(event) =>
setGrants((current) =>
event.target.checked
? [
...current,
{
groupId: group.id,
groupName: group.name,
isDefault: false,
},
]
: current.filter((entry) => entry.groupId !== group.id),
)
}
/>
{grant ? (
<label className="ml-auto flex items-center gap-1.5 text-xs text-slate-600">
<input
type="checkbox"
className="size-3.5 rounded border-slate-300 accent-indigo-600"
checked={grant.isDefault}
onChange={(event) =>
setGrants((current) =>
current.map((entry) =>
entry.groupId === group.id
? { ...entry, isDefault: event.target.checked }
: entry,
),
)
}
/>
Voreinstellung
</label>
) : null}
</li>
);
})}
</ul>
</div>
</Modal>
{/* -------------------------------------------------------- Löschen */}
<Modal
open={deleting !== null}
title="Domain löschen?"
onClose={() => setDeleting(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setDeleting(null)}>
Abbrechen
</Button>
<Button type="button" variant="danger" onClick={() => void remove()}>
Endgültig löschen
</Button>
</>
}
>
{deleting ? (
<p className="text-sm text-slate-600">
<strong className="font-mono text-slate-900">
{deleting.hostname}
</strong>{" "}
wird entfernt.
{deleting.codeCount > 0
? ` Unter dieser Domain liegen noch ${deleting.codeCount} ${deleting.codeCount === 1 ? "Code" : "Codes"} — das Löschen wird deshalb abgelehnt. Schalte sie stattdessen ab.`
: " Es liegen keine Codes darunter."}
</p>
) : null}
</Modal>
</div>
);
}