4 Commits
Author SHA1 Message Date
florian.wilde a7af9c9290 Merge #5: Filtermöglichkeit der angelegten Codes 2026-08-21 08:45:51 +00:00
florian.wilde 101f664cef Merge #4: Domains hinzufügen — Kurzhilfe erstellen 2026-08-21 08:43:16 +00:00
Florian Wilde 142536140c Meine Codes: Liste nach Art einschraenken
Seit statische Codes ablegbar sind, mischen sich zwei Arten in derselben
Liste. Eine Auswahl schraenkt sie auf dynamische oder statische ein — und
erscheint nur, wenn ueberhaupt beide vorkommen.

Zeigt die Liste nur statische Codes, faellt die Scan-Spalte weg und die
Sortierung nach Scans mit ihr: Statische zaehlen keine Scans, die Spalte
waere eine Reihe aus Strichen. Stand die Sortierung darauf, springt sie
auf "Neueste zuerst".

Die Beschreibung der Seite behauptete noch, statische Codes wuerden nicht
gespeichert. Das stimmt seit #2 nicht mehr.

Refs #5
2026-08-21 02:43:13 +02:00
Florian Wilde 1cf1df54df Kurzhilfe erklaert das Hinzufuegen einer Domain
Eine Domain zu registrieren genuegt nicht — DNS, Reverse Proxy und
Zertifikat muessen dazu passen. Bislang stand das nur im README, also
genau dort nicht, wo man es braucht.

Auf der Domain-Seite oeffnet ein Knopf die Erklaerung in sechs Schritten,
mit den drei noetigen Kopfzeilen und einem Pruefbefehl. Der Knopf selbst
ist als HelpButton allgemein gehalten, damit andere Verwaltungsseiten
spaeter dieselbe Form bekommen koennen.

Refs #4
2026-08-21 02:36:18 +02:00
8 changed files with 334 additions and 27 deletions
+7 -2
View File
@@ -618,13 +618,18 @@ Dazu kommen
- ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ,
- eine **Sortierung** nach Datum, Name oder Scan-Anzahl,
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen.
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen,
- ein **Filter nach Art**, sobald dynamische und statische Codes
nebeneinander liegen.
Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die
Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
Kartenansicht.
Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden.
Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein
Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Steht der
Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach
Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts.
Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
+3 -3
View File
@@ -27,9 +27,9 @@ export default async function MyCodesPage() {
Meine Codes
</h1>
<p className="mt-2 text-slate-600">
Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der
Scans. Statische Codes erscheinen hier nicht — sie werden nicht
gespeichert.
Dynamische Codes mit ihrem aktuellen Ziel und der Anzahl der Scans,
dazu abgelegte statische Codes. Über die Auswahl „Nach Art filtern“
lässt sich die Liste auf eine der beiden Arten einschränken.
</p>
</div>
+81 -19
View File
@@ -43,6 +43,8 @@ function formatShortDate(value: string | null): string {
type View = "list" | "cards";
type SortKey = "neueste" | "aelteste" | "name" | "scans";
/** Betriebsart, auf die die Liste eingeschränkt ist. */
type ModeFilter = "alle" | "dynamic" | "static";
const SORT_LABELS: Record<SortKey, string> = {
neueste: "Neueste zuerst",
@@ -51,6 +53,12 @@ const SORT_LABELS: Record<SortKey, string> = {
scans: "Meiste Scans",
};
const MODE_LABELS: Record<ModeFilter, string> = {
alle: "Alle Arten",
dynamic: "Nur dynamische",
static: "Nur statische",
};
/** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */
const VIEW_STORAGE_KEY = "qrgen.codes.view";
@@ -94,6 +102,7 @@ export function CodesList({
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortKey>("neueste");
const [groupFilter, setGroupFilter] = useState("alle");
const [modeFilter, setModeFilter] = useState<ModeFilter>("alle");
// Erst nach dem Einhängen lesen — auf dem Server gibt es keinen
// localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern.
@@ -107,6 +116,14 @@ export function CodesList({
window.localStorage.setItem(VIEW_STORAGE_KEY, next);
};
const changeModeFilter = (next: ModeFilter) => {
setModeFilter(next);
// Statische Codes zählen keine Scans. Bleibt die Sortierung danach
// stehen, sortiert die Liste nach lauter Nullen — und die Auswahl bietet
// eine Reihenfolge an, die es dort gar nicht gibt.
if (next === "static" && sort === "scans") setSort("neueste");
};
const load = useCallback(async () => {
try {
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
@@ -241,11 +258,25 @@ export function CodesList({
);
}, [codes]);
// Welche Betriebsarten überhaupt vorkommen. Ein Filter, der nichts zu
// filtern hat, steht nur im Weg — deshalb erscheint er erst, wenn beide
// Arten in der Liste stehen.
const kinds = useMemo(() => {
let dynamisch = false;
let statisch = false;
for (const code of codes ?? []) {
if (code.mode === "static") statisch = true;
else dynamisch = true;
}
return { dynamisch, statisch, beide: dynamisch && statisch };
}, [codes]);
const visible = useMemo(() => {
const needle = query.trim().toLowerCase();
const filtered = (codes ?? []).filter((code) => {
if (groupFilter !== "alle" && code.groupId !== groupFilter) return false;
if (modeFilter !== "alle" && code.mode !== modeFilter) return false;
if (!needle) return true;
return [
displayName(code),
@@ -273,7 +304,7 @@ export function CodesList({
}
});
return sorted;
}, [codes, query, sort, groupFilter]);
}, [codes, query, sort, groupFilter, modeFilter]);
/* ---------------------------------------------------------------- View */
@@ -287,6 +318,10 @@ export function CodesList({
const svgHref = (code: QrCodeDto) =>
`/api/codes/${code.id}/qr?format=svg&download=1`;
// Zeigt die Liste ausschließlich statische Codes, hat die Scan-Spalte
// nichts zu sagen — dann fällt sie weg, samt der Sortierung danach.
const nurStatische = modeFilter === "static" || (kinds.statisch && !kinds.dynamisch);
const toolbar = codes.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-0 flex-1 basis-56">
@@ -320,17 +355,36 @@ export function CodesList({
</Select>
) : null}
{kinds.beide ? (
<Select
aria-label="Nach Art filtern"
className="!w-auto"
value={modeFilter}
onChange={(event) =>
changeModeFilter(event.target.value as ModeFilter)
}
>
{(Object.keys(MODE_LABELS) as ModeFilter[]).map((key) => (
<option key={key} value={key}>
{MODE_LABELS[key]}
</option>
))}
</Select>
) : null}
<Select
aria-label="Sortierung"
className="!w-auto"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
>
{(Object.keys(SORT_LABELS) as SortKey[]).map((key) => (
<option key={key} value={key}>
{SORT_LABELS[key]}
</option>
))}
{(Object.keys(SORT_LABELS) as SortKey[])
.filter((key) => key !== "scans" || !nurStatische)
.map((key) => (
<option key={key} value={key}>
{SORT_LABELS[key]}
</option>
))}
</Select>
<div
@@ -406,12 +460,13 @@ export function CodesList({
{codes.length > 0 && visible.length === 0 ? (
<Card className="text-center">
<p className="text-sm text-slate-600">
Kein Code passt zur Suche.{" "}
Kein Code passt zu Suche und Filtern.{" "}
<button
type="button"
onClick={() => {
setQuery("");
setGroupFilter("alle");
setModeFilter("alle");
}}
className="font-medium text-accent-text underline"
>
@@ -446,9 +501,11 @@ export function CodesList({
Gruppe
</th>
) : null}
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
Scans
</th>
{nurStatische ? null : (
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
Scans
</th>
)}
<th
scope="col"
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
@@ -571,15 +628,20 @@ export function CodesList({
</td>
) : null}
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
{code.mode === "static" ? (
<span className="font-normal text-slate-400" title="Statische Codes werden nicht gezählt">
—
</span>
) : (
code.scans
)}
</td>
{nurStatische ? null : (
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
{code.mode === "static" ? (
<span
className="font-normal text-slate-400"
title="Statische Codes werden nicht gezählt"
>
—
</span>
) : (
code.scans
)}
</td>
)}
<td
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
+8 -2
View File
@@ -2,6 +2,8 @@
import { useCallback, useEffect, useState } from "react";
import { DomainHilfe } from "@/components/domain-hilfe";
import { HelpButton } from "@/components/help-button";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import {
@@ -134,10 +136,14 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
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.
gegen diese Liste — ein unbekannter Hostname endet mit 404. Was dafür
einzurichten ist, steht in der Kurzhilfe.
</Notice>
<div className="flex justify-end">
<div className="flex flex-wrap items-center justify-between gap-2">
<HelpButton title="Eine Domain hinzufügen" label="Kurzhilfe">
<DomainHilfe baseUrlHostname={baseUrlHostname} />
</HelpButton>
<Button type="button" onClick={() => setCreating(true)}>
Domain registrieren
</Button>
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { HelpCode, HelpStep } from "@/components/help-button";
/**
* Der Inhalt der Kurzhilfe auf der Domain-Seite.
*
* Verdichtet aus dem README-Abschnitt „Hinter einem Reverse Proxy“. Bewusst
* nur die Schritte, die diese Anwendung betreffen — wie man im eigenen Proxy
* einen Eintrag anlegt, steht in dessen Handbuch besser.
*/
export function DomainHilfe({ baseUrlHostname }: { baseUrlHostname: string }) {
const beispiel = baseUrlHostname || "qr.example.com";
return (
<div className="space-y-5">
<p>
Eine Domain hier zu registrieren genügt nicht — sie muss den Browser
auch hierher führen. Dazu gehören drei Dinge außerhalb dieser
Anwendung: ein DNS-Eintrag, ein Eintrag im Reverse Proxy und ein
Zertifikat.
</p>
<ol className="space-y-4">
<HelpStep n={1} title="DNS-Eintrag anlegen">
<p>
Beim Anbieter der Domain einen <strong>A-Record</strong> (bei IPv6
zusätzlich <strong>AAAA</strong>) auf die öffentliche Adresse des
Reverse Proxy setzen. Ein CNAME auf einen bestehenden Namen tut es
auch.
</p>
<p>
Steht der Name nur im internen DNS oder ist er nur über VPN
erreichbar, funktionieren gedruckte Codes{" "}
<strong>auch nur dort</strong>. Für Aushänge, Flyer und
Verpackungen muss er öffentlich auflösbar sein.
</p>
</HelpStep>
<HelpStep n={2} title="Im Reverse Proxy weiterleiten">
<p>
Für die Domain einen eigenen Eintrag anlegen, der auf diese
Anwendung zeigt — Ziel ist der Docker-Host auf Port{" "}
<code className="font-mono text-xs">3000</code>, oder im selben
Docker-Netz der Servicename{" "}
<code className="font-mono text-xs">app</code>.
</p>
<p>
Diese drei Kopfzeilen muss der Proxy mitschicken. Nginx Proxy
Manager, Traefik und Caddy tun das von sich aus; ein blankes nginx
braucht sie im Serverblock:
</p>
<HelpCode>{`proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`}</HelpCode>
<p>
Ohne <code className="font-mono text-xs">X-Forwarded-Host</code>{" "}
landet jeder Aufruf unter der Standard-Domain, und Kurz-URLs der
Zweitdomain werden nicht gefunden.
</p>
</HelpStep>
<HelpStep n={3} title="Zertifikat anfordern">
<p>
HTTPS erledigt der Proxy. Im Nginx Proxy Manager ist das der Reiter{" "}
<em>SSL</em> mit <em>Force SSL</em>; Traefik und Caddy holen das
Zertifikat selbst, sobald der DNS-Eintrag steht.
</p>
<p>
Ohne gültiges Zertifikat brechen viele Scanner-Apps den Aufruf ohne
Erklärung ab — der Code sieht dann für die Nutzer schlicht kaputt
aus.
</p>
</HelpStep>
<HelpStep n={4} title="Domain hier registrieren">
<p>
Über <strong>Domain registrieren</strong> den Hostnamen eintragen —
genau so, wie er im Browser steht, ohne{" "}
<code className="font-mono text-xs">https://</code> und ohne Pfad:
</p>
<HelpCode>{beispiel}</HelpCode>
<p>
Die Weiterleitung prüft den Hostnamen gegen diese Liste. Ein
unbekannter Name endet mit 404 — über eine gefälschte Kopfzeile
lässt sich also keine fremde Domain ansprechen.
</p>
</HelpStep>
<HelpStep n={5} title="Einer Gruppe zuteilen">
<p>
Eine frisch registrierte Domain gehört noch niemandem. Erst über{" "}
<strong>Gruppen zuteilen</strong> können Mitglieder dieser Gruppe
dort Codes anlegen. Die als <em>Voreinstellung</em> markierte
Domain ist die, die beim Anlegen vorausgewählt ist.
</p>
</HelpStep>
<HelpStep n={6} title="Nachprüfen">
<p>Von außerhalb des eigenen Netzes:</p>
<HelpCode>{`curl -I https://${beispiel}/`}</HelpCode>
<p>
Kommt eine Antwort dieser Anwendung, stimmen DNS, Proxy und
Zertifikat. Danach einen Code der zugeteilten Gruppe anlegen, die
Domain auswählen und den fertigen Code einmal mit dem Handy
scannen — das ist die einzige Prüfung, die wirklich zählt.
</p>
</HelpStep>
</ol>
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
<h3 className="font-semibold text-slate-900">
Was <code className="font-mono text-xs">BASE_URL</code> damit zu tun hat
</h3>
<p className="mt-1 text-slate-600">
Die Umgebungsvariable{" "}
<code className="font-mono text-xs">BASE_URL</code> ist die
Standard-Adresse: Sie greift bei Aufrufen über eine nicht
registrierte Adresse und bestimmt, ob Kurz-URLs mit{" "}
<code className="font-mono text-xs">http</code> oder{" "}
<code className="font-mono text-xs">https</code> gebildet werden.
Weitere Domains werden hier registriert, nicht dort eingetragen.
</p>
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useState, type ReactNode } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import { Button } from "@/components/ui";
/**
* Kurzhilfe: ein Fragezeichen neben einer Überschrift, das eine Erklärung
* aufklappt.
*
* Bewusst ein Dialog und kein Dauertext auf der Seite — die Erklärung braucht
* man einmal beim Einrichten und danach nie wieder. Sie ständig zu zeigen
* würde die Seite für alle anderen Aufrufe zumüllen.
*/
export function HelpButton({
title,
label = "Hilfe",
children,
}: {
/** Überschrift des Dialogs. */
title: string;
/** Beschriftung des Knopfes. */
label?: string;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setOpen(true)}
aria-haspopup="dialog"
>
<Icon name="help" className="size-4" />
{label}
</Button>
<Modal
open={open}
size="lg"
title={title}
onClose={() => setOpen(false)}
footer={
<Button type="button" onClick={() => setOpen(false)}>
Verstanden
</Button>
}
>
<div className="max-h-[65vh] overflow-y-auto pr-1 text-sm text-slate-700">
{children}
</div>
</Modal>
</>
);
}
/* ------------------------------------------------- Bausteine für den Inhalt */
/** Ein nummerierter Schritt mit Überschrift. */
export function HelpStep({
n,
title,
children,
}: {
n: number;
title: string;
children: ReactNode;
}) {
return (
<li className="flex gap-3">
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-fg">
{n}
</span>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-slate-900">{title}</h3>
<div className="mt-1 space-y-2 text-slate-600">{children}</div>
</div>
</li>
);
}
/** Ein Befehl oder Wert zum Abtippen — auf schmalen Schirmen scrollbar. */
export function HelpCode({ children }: { children: ReactNode }) {
return (
<pre className="overflow-x-auto rounded-lg bg-slate-100 px-3 py-2 font-mono text-xs text-slate-800">
{children}
</pre>
);
}
+7
View File
@@ -238,6 +238,13 @@ const PATHS = {
<path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" />
</>
),
help: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9.5a2.5 2.5 0 1 1 3.2 2.4c-.6.2-.95.75-.95 1.4v.45" />
<path d="M11.75 16.75h.01" />
</>
),
} as const;
export type IconName = keyof typeof PATHS;
+6 -1
View File
@@ -9,12 +9,15 @@ export function Modal({
onClose,
children,
footer,
size = "md",
}: {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
/** `lg` für längere Texte wie eine Kurzhilfe. */
size?: "md" | "lg";
}) {
const ref = useRef<HTMLDialogElement>(null);
@@ -39,7 +42,9 @@ export function Modal({
// Klick auf die Hintergrundfläche des Dialogs schließt ihn.
if (event.target === ref.current) onClose();
}}
className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50"
className={`m-auto w-[calc(100%-2rem)] ${
size === "lg" ? "max-w-2xl" : "max-w-lg"
} rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50`}
>
<div className="p-5 sm:p-6">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>