From df98aaaac94768148543cc7c8ed0bf517e9757d2 Mon Sep 17 00:00:00 2001 From: denode Date: Wed, 20 May 2026 20:47:07 +0200 Subject: [PATCH] Initial webiste --- .env.example | 7 + .gitignore | 18 + SETUP.md | 268 +++++++++++ docker-compose.yml | 20 + docker/pocketbase/Dockerfile | 15 + frontend/Dockerfile | 26 + frontend/app/blog/[slug]/page.tsx | 32 ++ frontend/app/blog/page.tsx | 41 ++ frontend/app/diplomatenkennzeichen/page.tsx | 158 ++++++ frontend/app/globals.css | 82 ++++ .../app/kennzeichen/KennzeichenFilter.tsx | 99 ++++ frontend/app/kennzeichen/page.tsx | 121 +++++ frontend/app/layout.tsx | 27 ++ frontend/app/page.tsx | 132 +++++ frontend/app/sammlung/page.tsx | 107 ++++ frontend/components/ui/Nav.tsx | 47 ++ frontend/lib/pb.ts | 150 ++++++ frontend/next.config.js | 11 + frontend/package.json | 33 ++ frontend/postcss.config.js | 6 + frontend/tailwind.config.ts | 32 ++ frontend/tsconfig.json | 21 + scripts/backup_pb.sh | 27 ++ scripts/cleanup.py | 246 ++++++++++ scripts/fix_schema.py | 238 +++++++++ scripts/import.py | 292 +++++++++++ scripts/import1.py | 455 ++++++++++++++++++ scripts/import_all.py | 455 ++++++++++++++++++ scripts/import_app_backup.sh | 43 ++ scripts/sync.sh | 58 +++ 30 files changed, 3267 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 SETUP.md create mode 100644 docker-compose.yml create mode 100644 docker/pocketbase/Dockerfile create mode 100644 frontend/Dockerfile create mode 100644 frontend/app/blog/[slug]/page.tsx create mode 100644 frontend/app/blog/page.tsx create mode 100644 frontend/app/diplomatenkennzeichen/page.tsx create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/kennzeichen/KennzeichenFilter.tsx create mode 100644 frontend/app/kennzeichen/page.tsx create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/app/sammlung/page.tsx create mode 100644 frontend/components/ui/Nav.tsx create mode 100644 frontend/lib/pb.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json create mode 100755 scripts/backup_pb.sh create mode 100644 scripts/cleanup.py create mode 100644 scripts/fix_schema.py create mode 100644 scripts/import.py create mode 100644 scripts/import1.py create mode 100644 scripts/import_all.py create mode 100755 scripts/import_app_backup.sh create mode 100755 scripts/sync.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7692eeb --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# PocketBase Admin-Zugangsdaten +# Kopieren nach .env und ausfüllen: +# cp .env.example .env + +PB_URL=http://localhost:4444 +PB_EMAIL=admin@example.com +PB_PASSWORD= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a31723 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Credentials +.env + +# PocketBase-Daten (werden nicht ins Repo eingecheckt) +backups/ +data.db + +# Claude Code intern +.claude/ + +# Next.js +frontend/.next/ +frontend/node_modules/ +frontend/.env.local + +# Python +__pycache__/ +*.pyc diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..ad44130 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,268 @@ +# Setup-Anleitung — Debian Server + +## Voraussetzungen + +- Debian 11 / 12 +- Root oder sudo-Zugriff +- Dein Hetzner Reverse Proxy zeigt auf diese Maschine +- Port 8888 im Reverse Proxy weitergeleitet + +--- + +## 1. Docker installieren (falls noch nicht vorhanden) + +```bash +# Alte Versionen entfernen +sudo apt remove docker docker-engine docker.io containerd runc 2>/dev/null + +# Abhängigkeiten +sudo apt update +sudo apt install -y ca-certificates curl gnupg + +# Docker GPG-Key +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/debian/gpg | \ + sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg + +# Repository +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ + https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Docker installieren +sudo apt update +sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +# Deinen User zur docker-Gruppe hinzufügen (kein sudo nötig) +sudo usermod -aG docker $USER +newgrp docker + +# Test +docker --version +docker compose version +``` + +--- + +## 2. Projektdateien auf den Server bringen + +```bash +# Auf deinem lokalen Rechner — Dateien hochladen +scp -r kennzeichen-website/ user@dein-server:/opt/kennzeichen-website + +# Oder: Git-Repo anlegen und pushen/pullen +# Dann auf dem Server: +cd /opt/kennzeichen-website +``` + +Alternativ direkt auf dem Server mit nano/vim die Dateien anlegen +(der Ordner existiert ja schon). + +--- + +## 3. Konfiguration anpassen + +```bash +cd /opt/kennzeichen-website + +# domain in docker-compose.yml setzen +nano docker-compose.yml +``` + +In der `docker-compose.yml` die zwei Umgebungsvariablen anpassen: +```yaml +environment: + - NEXT_PUBLIC_PB_URL=https://deine-echte-domain.de/api # ← anpassen + - PB_INTERNAL_URL=http://pocketbase:8090 # ← so lassen +``` + +--- + +## 4. PocketBase Admin-UI einmalig direkt exponieren + +Beim allerersten Start muss der Admin-Account angelegt werden. +Dafür kurz PocketBase direkt zugänglich machen: + +```bash +# In docker-compose.yml temporär unter pocketbase: hinzufügen: +# ports: +# - "8090:8090" +``` + +Dann starten: +```bash +docker compose up -d pocketbase +``` + +Im Browser aufrufen: `http://:8090/_/` +→ Admin-Account anlegen (E-Mail + Passwort merken!) + +Danach den `ports:`-Block wieder aus der docker-compose.yml entfernen. + +--- + +## 5. Alles starten + +```bash +cd /opt/kennzeichen-website + +# Beim ersten Mal: Images bauen (dauert 2–5 Min) +docker compose build + +# Starten +docker compose up -d + +# Status prüfen +docker compose ps + +# Logs ansehen +docker compose logs -f nextjs +docker compose logs -f pocketbase +``` + +Die Website ist jetzt unter Port 8888 erreichbar. + +--- + +## 6. Daten importieren + +```bash +# Python-Abhängigkeiten +pip3 install requests + +# plates-Repo klonen (falls noch nicht vorhanden) +git clone https://git.denode.eu/denode/plates /opt/plates + +# data.db auf den Server bringen +scp data.db user@dein-server:/opt/ + +# Import starten +cd /opt/kennzeichen-website +python3 scripts/import.py \ + --pb-url http://localhost:8090 \ + --pb-email deine@email.de \ + --pb-password deinpasswort \ + --repo-path /opt/plates \ + --db-path /opt/data.db +``` + +PocketBase ist beim Import direkt über localhost:8090 erreichbar +(kein Port nach außen nötig, nur von der Servermaschine selbst). + +--- + +## 7. Reverse Proxy konfigurieren + +In deinem Hetzner-Reverse-Proxy die Domain auf `:8888` zeigen lassen. + +Nginx Beispiel (auf dem Proxy-Server): +```nginx +server { + listen 443 ssl; + server_name deine-domain.de; + + # SSL-Zertifikat (z.B. Let's Encrypt) + ssl_certificate /etc/letsencrypt/live/deine-domain.de/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/deine-domain.de/privkey.pem; + + # Next.js + location / { + proxy_pass http://:8888; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # PocketBase API (wird von Next.js intern genutzt, + # aber für den Browser-SDK zugänglich machen) + location /api/ { + proxy_pass http://:8888/api/; + proxy_set_header Host $host; + } +} +``` + +--- + +## 8. Auto-Update bei Git-Änderungen (optional) + +```bash +# Cron-Job: täglich Daten aus plates-Repo neu importieren +crontab -e +``` + +```cron +# Jeden Tag um 03:00 Uhr +0 3 * * * cd /opt/plates && git pull && \ + python3 /opt/kennzeichen-website/scripts/import.py \ + --pb-url http://localhost:8090 \ + --pb-email deine@email.de \ + --pb-password deinpasswort \ + --repo-path /opt/plates \ + --db-path /opt/data.db \ + --skip-schema \ + >> /var/log/kennzeichen-import.log 2>&1 +``` + +--- + +## Nützliche Befehle + +```bash +# Neustart nach Code-Änderungen +docker compose build nextjs && docker compose up -d nextjs + +# PocketBase-Daten sichern +docker cp kennzeichen-website_pb_data_1:/pb/pb_data ./backup-$(date +%Y%m%d) + +# Logs verfolgen +docker compose logs -f + +# Alles stoppen +docker compose down +``` + +--- + +## Projektstruktur (Endzustand) + +``` +/opt/kennzeichen-website/ +├── docker-compose.yml +├── README.md +├── SCHEMA.md +├── docker/ +│ └── pocketbase/ +│ └── Dockerfile +├── frontend/ +│ ├── Dockerfile +│ ├── package.json +│ ├── next.config.js +│ ├── tailwind.config.ts +│ ├── tsconfig.json +│ ├── app/ +│ │ ├── layout.tsx # Root Layout + Navigation +│ │ ├── page.tsx # Startseite +│ │ ├── globals.css # Design-System +│ │ ├── kennzeichen/ +│ │ │ ├── page.tsx # Datenbank mit Suche + Filter +│ │ │ └── KennzeichenFilter.tsx +│ │ ├── diplomatenkennzeichen/ +│ │ │ └── page.tsx +│ │ ├── sammlung/ +│ │ │ └── page.tsx # Persönlicher Fortschritt +│ │ └── blog/ +│ │ ├── page.tsx # Blog-Übersicht +│ │ └── [slug]/ +│ │ └── page.tsx # Blog-Post +│ ├── components/ +│ │ └── ui/ +│ │ └── Nav.tsx +│ └── lib/ +│ └── pb.ts # PocketBase Client + Typen +└── scripts/ + ├── import.py + └── validate.py +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ec470fd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + pocketbase: + build: ./docker/pocketbase + restart: unless-stopped + volumes: + - pb_data:/pb/pb_data + # ports: + #- "4444:8090" + nextjs: + build: ./frontend + restart: unless-stopped + ports: + - "8888:3000" + environment: + - NEXT_PUBLIC_PB_URL=https://plates.denode.eu/api + - PB_INTERNAL_URL=http://pocketbase:8090 + depends_on: + - pocketbase +volumes: + pb_data: diff --git a/docker/pocketbase/Dockerfile b/docker/pocketbase/Dockerfile new file mode 100644 index 0000000..577957b --- /dev/null +++ b/docker/pocketbase/Dockerfile @@ -0,0 +1,15 @@ +FROM alpine:3.19 + +ARG PB_VERSION=0.22.20 + +RUN apk add --no-cache unzip ca-certificates wget + +RUN wget -q "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip" \ + -O /tmp/pb.zip && \ + unzip /tmp/pb.zip -d /pb && \ + rm /tmp/pb.zip + +WORKDIR /pb +EXPOSE 8090 + +CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090"] diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c560ed3 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,26 @@ +FROM node:20-alpine AS base + +FROM base AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs +RUN mkdir -p ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] diff --git a/frontend/app/blog/[slug]/page.tsx b/frontend/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..7f1ba9e --- /dev/null +++ b/frontend/app/blog/[slug]/page.tsx @@ -0,0 +1,32 @@ +export const dynamic = 'force-dynamic'; +import { pb, type BlogPost } from "@/lib/pb"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +interface Props { params: { slug: string } } + +async function getPost(slug: string) { + try { return await pb.collection("blog_posts").getFirstListItem(`slug="${slug}"`); } + catch { return null; } +} + +export default async function BlogPostPage({ params }: Props) { + const post = await getPost(params.slug); + if (!post) notFound(); + const html = post.inhalt + .replace(/^### (.+)$/gm,"

$1

").replace(/^## (.+)$/gm,"

$1

").replace(/^# (.+)$/gm,"

$1

") + .replace(/\*\*(.+?)\*\*/g,"$1").replace(/\*(.+?)\*/g,"$1") + .replace(/`(.+?)`/g,"$1").replace(/\[(.+?)\]\((.+?)\)/g,'$1') + .replace(/\n\n/g,"

").replace(/^/,"

").replace(/$/,"

"); + return ( +
+ ← Blog +
+ +

{post.titel}

+ {post.tags?.length > 0 &&
{post.tags.map(tag => {tag})}
} +
+
+
+ ); +} diff --git a/frontend/app/blog/page.tsx b/frontend/app/blog/page.tsx new file mode 100644 index 0000000..f4dd271 --- /dev/null +++ b/frontend/app/blog/page.tsx @@ -0,0 +1,41 @@ +export const dynamic = 'force-dynamic'; +import { pb, type BlogPost } from "@/lib/pb"; +import Link from "next/link"; + +export const metadata = { title: "Blog" }; + +async function getPosts() { + try { return pb.collection("blog_posts").getList(1, 20, { sort: "-created" }); } + catch { return null; } +} + +export default async function BlogPage() { + const data = await getPosts(); + const posts = data?.items ?? []; + return ( +
+
+
Notizen
+

Blog

+

Funde, Besonderheiten und Gedanken rund ums Sammeln.

+
+ {posts.length === 0 ? ( +
+
Noch keine Beiträge
+

Schreib den ersten Eintrag über den PocketBase Admin.

+
+ ) : ( +
+ {posts.map((post, i) => ( +
+ +

{post.titel}

+ {post.inhalt &&

{post.inhalt.replace(/[#*`\[\]]/g,"").slice(0,220)}…

} + +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/app/diplomatenkennzeichen/page.tsx b/frontend/app/diplomatenkennzeichen/page.tsx new file mode 100644 index 0000000..4a6271e --- /dev/null +++ b/frontend/app/diplomatenkennzeichen/page.tsx @@ -0,0 +1,158 @@ +export const dynamic = 'force-dynamic'; +import { pb, type Diplomatenkennzeichen } from "@/lib/pb"; + +export const metadata = { title: "Diplomatenkennzeichen" }; + +const COUNTRY_NAMES: Record = { + germany: "Deutschland", austria: "Österreich", + switzerland: "Schweiz", luxembourg: "Luxemburg", +}; +const COUNTRY_FLAGS: Record = { + germany: "🇩🇪", austria: "🇦🇹", switzerland: "🇨🇭", luxembourg: "🇱🇺", +}; +const TYPE_LABELS: Record = { + embassy: "Botschaft", consulate: "Konsulat", io: "Intern. Organisation", +}; +const TYPE_COLORS: Record = { + embassy: "bg-blue-50 text-blue-800 border-blue-200", + consulate: "bg-amber-50 text-amber-800 border-amber-200", + io: "bg-emerald-50 text-emerald-800 border-emerald-200", +}; + +interface Props { searchParams: { land?: string } } + +async function getData(baseCountry: string) { + try { + // Ohne sort um 400 zu vermeiden, sortieren wir client-side + return pb.collection("diplomatenkennzeichen").getFullList({ + filter: `base_country="${baseCountry}"`, + }); + } catch { return []; } +} + +async function getAvailableCountries(): Promise { + try { + const all = await pb.collection("diplomatenkennzeichen").getFullList({ + fields: "base_country", + }); + return [...new Set(all.map((i: any) => i.base_country).filter(Boolean))].sort() as string[]; + } catch { return ["germany"]; } +} + +function sortByCode(a: Diplomatenkennzeichen, b: Diplomatenkennzeichen) { + const parseCode = (c: string) => { + const m = c.match(/^(?:0-)?(\d+)$/); + return m ? parseInt(m[1]) : -1; + }; + const na = parseCode(a.code); + const nb = parseCode(b.code); + if (na >= 0 && nb >= 0) return na - nb; + if (na >= 0) return -1; + if (nb >= 0) return 1; + return a.code.localeCompare(b.code); +} + +export default async function DiplomatenPage({ searchParams }: Props) { + const countries = await getAvailableCountries(); + const activeLand = searchParams.land ?? countries[0] ?? "germany"; + const rawItems = await getData(activeLand); + const items = [...rawItems].sort(sortByCode); + + const grouped = items.reduce>((acc, item) => { + const t = item.type ?? "embassy"; + if (!acc[t]) acc[t] = []; + acc[t].push(item); + return acc; + }, {}); + + return ( +
+
+
Datenbank
+

Diplomatenkennzeichen

+

+ Sonderkennzeichen für Botschaften, Konsulate und internationale Organisationen. +

+
+ + {/* Länder-Tabs */} + + +
+ {Object.entries(TYPE_LABELS).map(([key, label]) => ( + + {label}: {grouped[key]?.length ?? 0} + + ))} + — {items.length} Einträge gesamt +
+ + {items.length === 0 ? ( +
Keine Daten für dieses Land.
+ ) : ( + Object.entries(TYPE_LABELS).map(([type, typeLabel]) => { + const entries = grouped[type]; + if (!entries?.length) return null; + return ( +
+

+ {typeLabel} + {entries.length} Einträge +

+
+
+ + + + + + + + + + + {entries.map((d) => ( + + + + + + + ))} + +
KürzelLand / OrganisationStadtBemerkung
{d.code} +
{d.country}
+ {d.organisation &&
{d.organisation}
} +
{d.city}{d.note}
+
+
+
+ ); + }) + )} + +
+

Daten unvollständig oder fehlerhaft?

+ + git.denode.eu/denode/plates → + +
+
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..e93fe1c --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,82 @@ +@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&family=JetBrains+Mono:wght@400;500&display=swap'); +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --ink: #111110; + --paper: #F7F7F5; + --warm: #E2E2DE; + --accent: #C8440A; + --muted: #6E6E6A; +} + +* { box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + background: var(--paper); + color: var(--ink); + font-family: 'DM Sans', sans-serif; + font-size: 15px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +/* Kennzeichen-Badge */ +.kz-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-family: 'JetBrains Mono', monospace; + font-weight: 500; + font-size: 13px; + padding: 3px 10px; + border: 1.5px solid var(--ink); + border-radius: 2px; + background: white; + letter-spacing: 0.05em; + transition: all 0.15s ease; + cursor: default; +} +.kz-badge:hover { + background: var(--ink); + color: var(--paper); +} +.kz-badge.seen { + background: var(--ink); + color: var(--paper); +} + +/* Tabellen */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +.data-table th { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); + text-align: left; + padding: 10px 16px; + border-bottom: 1.5px solid var(--warm); +} +.data-table td { + padding: 10px 16px; + border-bottom: 1px solid var(--warm); + vertical-align: middle; +} +.data-table tr:hover td { + background: rgba(200,68,10,0.04); +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--warm); border-radius: 2px; } +::-webkit-scrollbar-thumb:hover { background: var(--muted); } diff --git a/frontend/app/kennzeichen/KennzeichenFilter.tsx b/frontend/app/kennzeichen/KennzeichenFilter.tsx new file mode 100644 index 0000000..0b9e138 --- /dev/null +++ b/frontend/app/kennzeichen/KennzeichenFilter.tsx @@ -0,0 +1,99 @@ +"use client"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useState } from "react"; + +interface Country { + key: string; + label: string; + flag: string; + count: number; +} + +interface Props { + countries: Country[]; + activeLand?: string; + activeQ?: string; + historisch?: boolean; +} + +export default function KennzeichenFilter({ countries, activeLand, activeQ, historisch }: Props) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [q, setQ] = useState(activeQ ?? ""); + + const update = useCallback((updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + for (const [k, v] of Object.entries(updates)) { + if (v) params.set(k, v); + else params.delete(k); + } + params.delete("seite"); + router.push(`?${params.toString()}`); + }, [router, searchParams]); + + return ( +
+ {/* Suchfeld */} +
+ setQ(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && update({ q: q || undefined })} + placeholder="Kürzel, Ort oder Region suchen…" + className="w-full px-4 py-3 pr-24 border border-[var(--warm)] rounded-lg bg-white/60 text-sm focus:outline-none focus:border-[var(--ink)] transition-colors" + /> + +
+ + {/* Länder-Filter */} +
+ + {countries + .filter((c) => c.count > 0) + .sort((a, b) => b.count - a.count) + .map((c) => ( + + ))} +
+ + {/* Historisch-Toggle */} + +
+ ); +} diff --git a/frontend/app/kennzeichen/page.tsx b/frontend/app/kennzeichen/page.tsx new file mode 100644 index 0000000..3f6728e --- /dev/null +++ b/frontend/app/kennzeichen/page.tsx @@ -0,0 +1,121 @@ +export const dynamic = 'force-dynamic'; +import { pb, COUNTRY_LABELS, COUNTRY_FLAGS, type Kennzeichen } from "@/lib/pb"; +import KennzeichenFilter from "./KennzeichenFilter"; + +interface Props { searchParams: { land?: string; q?: string; seite?: string; historisch?: string } } +export const metadata = { title: "Datenbank" }; + +async function getData(land?: string, q?: string, page = 1, historisch = false) { + try { + const safe = (s: string) => s.replace(/["\\]/g, ""); + const filters: string[] = [`country!="global"`]; + if (land) filters.push(`country="${safe(land)}"`); + if (!historisch) filters.push(`active=true`); + if (q) { const s = safe(q); filters.push(`(code~"${s}" || name~"${s}" || region~"${s}")`); } + return pb.collection("kennzeichen").getList(page, 100, { + filter: filters.join(" && ") || undefined, sort: "code", + }); + } catch { return null; } +} + +async function getCountryCounts() { + try { + const all = await pb.collection("kennzeichen").getFullList({ fields: "country", filter: 'active=true && country!="global"' }); + const counts: Record = {}; + for (const k of all) counts[k.country] = (counts[k.country] ?? 0) + 1; + return counts; + } catch { return {}; } +} + +function Pagination({ page, totalPages, land, q }: { page: number; totalPages: number; land?: string; q?: string }) { + if (totalPages <= 1) return null; + const makeHref = (p: number) => { + const params = new URLSearchParams(); + if (land) params.set("land", land); + if (q) params.set("q", q); + params.set("seite", String(p)); + return `?${params.toString()}`; + }; + const pages: (number | string)[] = []; + for (let p = 1; p <= totalPages; p++) { + if (p === 1 || p === totalPages || Math.abs(p - page) <= 2) { + pages.push(p); + } else if (pages[pages.length - 1] !== "…") { + pages.push("…"); + } + } + return ( +
+ {page > 1 && ( + + )} + {pages.map((p, i) => + p === "…" ? ( + + ) : ( + + {p} + + ) + )} + {page < totalPages && ( + + )} +
+ ); +} + +export default async function KennzeichenPage({ searchParams }: Props) { + const land = searchParams.land; + const q = searchParams.q; + const page = parseInt(searchParams.seite ?? "1"); + const historisch = searchParams.historisch === "1"; + const [data, counts] = await Promise.all([getData(land, q, page, historisch), getCountryCounts()]); + const items = data?.items ?? []; + + return ( +
+
+
Datenbank
+

Kennzeichen

+ {data &&

{data.totalItems.toLocaleString("de")} Einträge

} +
+ ({ key, label, flag: COUNTRY_FLAGS[key] ?? "🌍", count: counts[key] ?? 0 }))} + activeLand={land} activeQ={q} historisch={historisch} + /> +
+ {items.length === 0 ? ( +
Keine Kennzeichen gefunden.
+ ) : ( +
+ + + + + + + + + + + {items.map((kz) => ( + + + + + + + + + ))} + +
KürzelOrt / KreisHerleitungRegionPunkteBemerkung
{kz.code}{kz.name}{kz.derivation}{kz.region}{kz.points ?? "—"}{kz.note}
+
+ )} +
+ {data && } +
+ ); +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..b74c17c --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import Nav from "@/components/ui/Nav"; + +export const metadata: Metadata = { + title: { default: "Kennzeichensammler", template: "%s | Kennzeichensammler" }, + description: "Persönliche Kennzeichen-Datenbank und Blog", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +