Docker-Stack, README und Korrekturen aus dem ersten Durchlauf

- Dockerfile (4 Stufen) und docker-compose.yml mit Postgres-Healthcheck,
  Volume und direktem Port-Mapping 3000:3000, ohne Reverse Proxy.
- Entrypoint wendet beim Start "prisma migrate deploy" an; die Prisma-CLI
  bekommt eine eigene Build-Stufe, weil sie mehr als nur @prisma/* braucht.
- prisma.config.ts durch prisma.config.js ersetzt: laedt ohne TypeScript und
  ohne dotenv, damit sie im Laufzeit-Image funktioniert.
- Prisma-Client wird erst beim ersten Zugriff erzeugt. Vorher scheiterte
  "next build" beim Einsammeln der Routen-Konfiguration ohne DATABASE_URL.
- Postgres-18-Volume auf /var/lib/postgresql gemountet (neues Datenlayout).
- npm 11 blockiert Installationsskripte: erlaubte Pakete stehen jetzt im
  Feld "allowScripts" der package.json.
- Lokale Variable "module" ueberall in "qrType" umbenannt (verdeckte sonst
  die CommonJS-Variable) und Vorschau-Effekt ohne synchrones setState.
- README.md auf Deutsch: Setup, Konfiguration, Bedienung, Aufbau, API und
  Ausblick auf Version 2.
This commit is contained in:
Florian Wilde
2026-08-13 16:48:28 +02:00
parent ad4cd92f8e
commit 5806dc4ce0
26 changed files with 751 additions and 129 deletions
+22 -20
View File
@@ -43,10 +43,10 @@ function sameData(a: unknown, b: unknown): boolean {
}
export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
const module = qrTypes[typeId];
const qrType = qrTypes[typeId];
const Form = qrForms[typeId];
const [data, setData] = useState<QrFormData>(module.defaultData);
const [data, setData] = useState<QrFormData>(qrType.defaultData);
const [design, setDesign] = useState<QrDesign>(defaultDesign);
const [isDynamic, setIsDynamic] = useState(false);
const [label, setLabel] = useState("");
@@ -62,14 +62,14 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
const [downloading, setDownloading] = useState<"png" | "svg" | null>(null);
const [downloadError, setDownloadError] = useState<string | null>(null);
const validation = useMemo(() => module.validate(data), [module, data]);
const validation = useMemo(() => qrType.validate(data), [qrType, data]);
const designValid = useMemo(
() => designSchema.safeParse(design).success,
[design],
);
// Fehler erst zeigen, wenn tatsächlich etwas eingegeben wurde.
const touched = !sameData(data, module.defaultData);
const touched = !sameData(data, qrType.defaultData);
const dataError = !validation.ok && touched ? validation.message : null;
const mode = isDynamic ? "dynamic" : "static";
@@ -87,18 +87,13 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
const requestId = useRef(0);
useEffect(() => {
if (!ready) {
setPreview(null);
setPreviewError(null);
setPreviewLoading(false);
return;
}
if (!ready) return;
const id = (requestId.current += 1);
const controller = new AbortController();
setPreviewLoading(true);
const timer = setTimeout(() => {
setPreviewLoading(true);
apiJson<PreviewState>("/api/qr/preview", {
method: "POST",
json: requestBody,
@@ -129,6 +124,13 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
};
}, [ready, requestBody]);
// Solange die Eingaben unvollständig sind, gibt es nichts anzuzeigen. Das
// wird abgeleitet statt im Effekt zurückgesetzt — so bleibt beim Tippen die
// zuletzt gültige Vorschau stehen, statt bei jedem Zeichen zu flackern.
const shownPreview = ready ? preview : null;
const shownPreviewError = ready ? previewError : null;
const shownPreviewLoading = ready && previewLoading;
/* ------------------------------------------------------------ Download */
const downloadDisabledReason =
@@ -224,13 +226,13 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<div className="mb-7 flex flex-wrap items-center gap-3">
<span className="flex size-11 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600">
<Icon name={module.icon} className="size-6" />
<Icon name={qrType.icon} className="size-6" />
</span>
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">
{module.title}-QR-Code erstellen
{qrType.title}-QR-Code erstellen
</h1>
<p className="text-sm text-slate-600">{module.description}</p>
<p className="text-sm text-slate-600">{qrType.description}</p>
</div>
</div>
@@ -279,7 +281,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<button
type="button"
aria-pressed={isDynamic}
disabled={!module.supportsDynamic}
disabled={!qrType.supportsDynamic}
onClick={() => setIsDynamic(true)}
className={cx(
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
@@ -353,7 +355,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
href="/meine-codes"
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-indigo-600 hover:text-indigo-700"
>
Zu „Meine Codes"
Zu „Meine Codes“
<Icon name="external" className="size-3.5" />
</Link>
</div>
@@ -401,11 +403,11 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<Card>
<CardTitle>Vorschau</CardTitle>
<QrPreview
dataUrl={preview?.dataUrl ?? null}
content={preview?.content ?? null}
dataUrl={shownPreview?.dataUrl ?? null}
content={shownPreview?.content ?? null}
size={design.size}
error={previewError ?? downloadError}
loading={previewLoading}
error={shownPreviewError ?? downloadError}
loading={shownPreviewLoading}
downloadDisabledReason={downloadDisabledReason}
onDownload={handleDownload}
downloading={downloading}