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
+3 -3
View File
@@ -55,9 +55,9 @@ export async function GET(
};
if (isDownload) {
const module = getQrType(code.type);
const name = module
? downloadFilename(module, code.data, format)
const qrType = getQrType(code.type);
const name = qrType
? downloadFilename(qrType, code.data, format)
: `qr-${code.slug}.${format}`;
headers["Content-Disposition"] = `attachment; filename="${name}"`;
}
+3 -3
View File
@@ -60,13 +60,13 @@ export async function PATCH(
const update: Prisma.QrCodeUpdateInput = {};
if (parsed.data.data !== undefined) {
const module = getActiveQrType(existing.type);
if (!module) {
const qrType = getActiveQrType(existing.type);
if (!qrType) {
throw new QrRequestError(
"Der Typ dieses Codes wird in dieser Version nicht unterstützt.",
);
}
const validated = module.validate(parsed.data.data);
const validated = qrType.validate(parsed.data.data);
if (!validated.ok) {
return jsonError(validated.message);
}
+6 -6
View File
@@ -51,20 +51,20 @@ export async function POST(request: NextRequest): Promise<Response> {
}
const { type, design, label } = parsed.data;
const module = getActiveQrType(type);
if (!module) {
const qrType = getActiveQrType(type);
if (!qrType) {
throw new QrRequestError(
"Dieser QR-Typ ist in dieser Version noch nicht verfügbar.",
404,
);
}
if (!module.supportsDynamic) {
if (!qrType.supportsDynamic) {
throw new QrRequestError(
`Der QR-Typ „${module.title}" unterstützt keine dynamischen Codes.`,
`Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`,
);
}
const validated = module.validate(parsed.data.data);
const validated = qrType.validate(parsed.data.data);
if (!validated.ok) {
return jsonError(validated.message);
}
@@ -75,7 +75,7 @@ export async function POST(request: NextRequest): Promise<Response> {
const created = await prisma.qrCode.create({
data: {
slug: generateSlug(),
type: module.id,
type: qrType.id,
label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data),
...design,
+2 -2
View File
@@ -31,7 +31,7 @@ export async function POST(request: NextRequest): Promise<Response> {
);
}
const { module, data, content } = resolveQrRequest(parsed.data);
const { qrType, data, content } = resolveQrRequest(parsed.data);
const body =
format === "png"
@@ -41,7 +41,7 @@ export async function POST(request: NextRequest): Promise<Response> {
return new Response(body, {
headers: {
"Content-Type": CONTENT_TYPES[format],
"Content-Disposition": `attachment; filename="${downloadFilename(module, data, format)}"`,
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format)}"`,
"Cache-Control": "no-store",
},
});
+7 -7
View File
@@ -15,15 +15,15 @@ export function generateStaticParams() {
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { type } = await params;
const module = getQrType(type);
return { title: module ? `${module.title}-QR-Code` : "QR-Code erstellen" };
const qrType = getQrType(type);
return { title: qrType ? `${qrType.title}-QR-Code` : "QR-Code erstellen" };
}
export default async function CreatePage({ params }: Props) {
const { type } = await params;
const module = getQrType(type);
const qrType = getQrType(type);
if (!module) {
if (!qrType) {
notFound();
}
@@ -33,10 +33,10 @@ export default async function CreatePage({ params }: Props) {
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={module.icon} className="size-7" />
<Icon name={qrType.icon} className="size-7" />
</span>
<h1 className="mt-5 text-2xl font-bold text-slate-900">
„{module.title}“ ist bald verfügbar
„{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“
@@ -52,5 +52,5 @@ export default async function CreatePage({ params }: Props) {
);
}
return <QrGenerator typeId={module.id} />;
return <QrGenerator typeId={qrType.id} />;
}
+1 -1
View File
@@ -49,7 +49,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
<footer className="border-t border-slate-200 bg-white">
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
QR-Code-Generator · Version 1 — QR-Typ „Website/URL"
QR-Code-Generator · Version 1 — QR-Typ „Website/URL“
</div>
</footer>
</body>
+2 -2
View File
@@ -73,7 +73,7 @@ export default function HomePage() {
) : (
<div
aria-disabled="true"
title={`„${type.title}" folgt in Version 2`}
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}
@@ -85,7 +85,7 @@ export default function HomePage() {
</ul>
<p className="mt-8 text-sm text-slate-500">
In Version 1 ist der Typ „Website" verfügbar. Die übrigen Typen folgen in
In Version 1 ist der Typ „Website“ verfügbar. Die übrigen Typen folgen in
Version 2.
</p>
</div>
+3 -3
View File
@@ -52,14 +52,14 @@ export async function GET(
);
}
const module = getQrType(code.type);
if (!module) {
const qrType = getQrType(code.type);
if (!qrType) {
return notFound("Der Typ dieses Codes wird nicht mehr unterstützt.");
}
let target: string;
try {
target = module.redirectTarget(code.data);
target = qrType.redirectTarget(code.data);
} catch {
return notFound("Das Ziel dieses Codes ist nicht mehr gültig.");
}
+3
View File
@@ -54,6 +54,9 @@ export function CodesList() {
}, []);
useEffect(() => {
// `load` setzt den Zustand erst nach dem await der Netzwerkantwort, also
// nie synchron während des Effekts — die Regel kann das nicht erkennen.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
+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}
+4 -4
View File
@@ -28,14 +28,14 @@ function asRecord(value: unknown): Record<string, unknown> {
export function toQrCodeDto(code: QrCode): QrCodeDto {
const data = asRecord(code.data);
const module = getQrType(code.type);
const qrType = getQrType(code.type);
let target = "";
let summary = "";
if (module) {
if (qrType) {
try {
target = module.redirectTarget(data);
summary = module.summary(data);
target = qrType.redirectTarget(data);
summary = qrType.summary(data);
} catch {
// Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem
// Schema-Wechsel). Die Liste soll trotzdem anzeigbar bleiben.
+32 -12
View File
@@ -5,20 +5,40 @@ import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import { getDatabaseUrl } from "@/lib/env";
type Client = InstanceType<typeof PrismaClient>;
// Im Entwicklungsmodus wird das Modul bei jedem Hot-Reload neu ausgewertet.
// Ohne diesen Cache entstünde pro Reload ein neuer Verbindungspool.
const globalForPrisma = globalThis as unknown as {
prisma?: ReturnType<typeof createPrismaClient>;
};
const globalForPrisma = globalThis as unknown as { prisma?: Client };
function createPrismaClient() {
return new PrismaClient({
adapter: new PrismaPg({ connectionString: getDatabaseUrl() }),
});
let client: Client | undefined;
function getClient(): Client {
if (!client) {
client =
globalForPrisma.prisma ??
new PrismaClient({
adapter: new PrismaPg({ connectionString: getDatabaseUrl() }),
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = client;
}
}
return client;
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
/**
* Der Client wird erst beim ersten Zugriff erzeugt.
*
* Beim Produktionsbuild lädt Next.js die Routen-Module, um ihre Konfiguration
* einzusammeln. Zu diesem Zeitpunkt gibt es weder eine Datenbank noch eine
* DATABASE_URL — ein sofort verbindender Client würde den Build scheitern
* lassen.
*/
export const prisma: Client = new Proxy({} as Client, {
get(_target, property) {
const instance = getClient() as unknown as Record<string | symbol, unknown>;
const value = instance[property];
return typeof value === "function" ? value.bind(instance) : value;
},
});
+1 -1
View File
@@ -3,7 +3,7 @@
*
* Scanner erkennen einen Code über den Helligkeitsunterschied zwischen
* Modul- und Hintergrundfarbe. Zu geringer Kontrast — und ebenso ein heller
* Vordergrund auf dunklem Hintergrund („invertierter" Code) — führt bei
* Vordergrund auf dunklem Hintergrund („invertierter“ Code) — führt bei
* vielen Kameras zu Lesefehlern.
*/
+3 -3
View File
@@ -105,7 +105,7 @@ const MODULES: readonly QrTypeModule[] = [
];
export const qrTypes: Record<QrTypeId, QrTypeModule> = Object.fromEntries(
MODULES.map((module) => [module.id, module]),
MODULES.map((qrType) => [qrType.id, qrType]),
) as Record<QrTypeId, QrTypeModule>;
const missing = QR_TYPE_IDS.filter((id) => !qrTypes[id]);
@@ -122,8 +122,8 @@ export function getQrType(id: string): QrTypeModule | undefined {
/** Liefert den Typ nur, wenn er in dieser Version nutzbar ist. */
export function getActiveQrType(id: string): QrTypeModule | undefined {
const module = getQrType(id);
return module?.active ? module : undefined;
const qrType = getQrType(id);
return qrType?.active ? qrType : undefined;
}
export { QR_TYPE_IDS, isQrTypeId };
+11 -11
View File
@@ -41,7 +41,7 @@ export class QrRequestError extends Error {
}
export type ResolvedQrRequest = {
module: QrTypeModule;
qrType: QrTypeModule;
data: Record<string, unknown>;
/** Der Text, der tatsächlich in den QR-Code kodiert wird. */
content: string;
@@ -52,23 +52,23 @@ export type ResolvedQrRequest = {
* bei statischen Codes die Nutzdaten selbst, bei dynamischen die Kurz-URL.
*/
export function resolveQrRequest(request: QrRequest): ResolvedQrRequest {
const module = getActiveQrType(request.type);
if (!module) {
const qrType = getActiveQrType(request.type);
if (!qrType) {
throw new QrRequestError(
"Dieser QR-Typ ist in dieser Version noch nicht verfügbar.",
404,
);
}
const validated = module.validate(request.data);
const validated = qrType.validate(request.data);
if (!validated.ok) {
throw new QrRequestError(validated.message);
}
if (request.mode === "dynamic") {
if (!module.supportsDynamic) {
if (!qrType.supportsDynamic) {
throw new QrRequestError(
`Der QR-Typ „${module.title}" unterstützt keine dynamischen Codes.`,
`Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`,
);
}
const slug = request.slug;
@@ -76,28 +76,28 @@ export function resolveQrRequest(request: QrRequest): ResolvedQrRequest {
throw new QrRequestError("Ungültige Kurz-URL.");
}
return {
module,
qrType,
data: validated.data,
content: shortUrl(slug ?? PREVIEW_SLUG),
};
}
return {
module,
qrType,
data: validated.data,
content: module.encode(validated.data),
content: qrType.encode(validated.data),
};
}
/** Dateiname für den Download, abgeleitet aus dem Inhalt des Codes. */
export function downloadFilename(
module: QrTypeModule,
qrType: QrTypeModule,
data: unknown,
extension: string,
): string {
let base = "qr-code";
try {
const summary = module
const summary = qrType
.summary(data)
.toLowerCase()
.replaceAll("ä", "ae")
+3 -3
View File
@@ -5,7 +5,7 @@ import type { IconName } from "@/components/icons";
/**
* Alle QR-Typen, die die Anwendung kennt. V1 implementiert davon nur
* "website"; die übrigen Einträge sind als Platzhalter registriert und
* erscheinen in der Oberfläche mit dem Badge „Bald verfügbar".
* erscheinen in der Oberfläche mit dem Badge „Bald verfügbar“.
*/
export const QR_TYPE_IDS = [
"website",
@@ -148,7 +148,7 @@ export function defineComingSoonType(spec: {
}): QrTypeModule {
const notAvailable = (): never => {
throw new QrTypeError(
`Der QR-Typ „${spec.title}" ist in dieser Version noch nicht verfügbar.`,
`Der QR-Typ „${spec.title}“ ist in dieser Version noch nicht verfügbar.`,
);
};
@@ -162,7 +162,7 @@ export function defineComingSoonType(spec: {
defaultData: {},
validate: () => ({
ok: false,
message: `Der QR-Typ „${spec.title}" ist in dieser Version noch nicht verfügbar.`,
message: `Der QR-Typ „${spec.title}“ ist in dieser Version noch nicht verfügbar.`,
}),
encode: notAvailable,
redirectTarget: notAvailable,