Compare commits

..

1 commit

Author SHA1 Message Date
Daniel Michelberger
279796111c wip: Der abgebrochene Kurbeler-Umbau, wie er hier liegengeblieben ist
Kein fertiger Stand, sondern ein Zwischenstand: Hier wurde im September 2026
angefangen, die App auf „Kurbeler" umzubenennen — Wortmarke, Icons, brand.ts,
Benachrichtigungen, das Benutzerverzeichnis. Weitergegangen ist es dann im
eigenen Repo `~/Dokumente/Projekte/kurbeler`, das die vollständige Historie
dieses Repos mitgenommen hat.

Committet wird das hier nicht, weil es gebraucht würde, sondern damit es nicht
als Haufen unversionierter Dateien im Arbeitsverzeichnis verrottet. Wer in
zwei Jahren nachsieht, findet so eine Geschichte statt eines Rätsels — und das
Stammtisch-Wappen, das nur hier je existiert hat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTw6xVYgjMy9AQtfs1Gdyu
2026-09-11 15:42:33 +02:00
227 changed files with 25765 additions and 903 deletions

216
CLAUDE.md
View file

@ -1,46 +1,198 @@
# CLAUDE.md
## Was dieses Repo ist
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Ein Wegweiser, keine Anwendung. Drei vorgerenderte Seiten unter
`stammtisch-hersbruck.de`: Wappen, ein Satz, ein Knopf nach
[kurbeler.de](https://kurbeler.de), dazu Impressum und Datenschutz.
## Project Overview
Die Anwendung, die hier bis September 2026 lag, heißt jetzt **Kurbeler** und
wird in `~/Dokumente/Projekte/kurbeler` fortgeführt. **Neue Features gehören
dorthin, nicht hierher.** Wer hier eine Zeitnahme, einen Kader oder ein Event
einbaut, baut sie ein zweites Mal — und ab dem ersten Tag läuft sie
auseinander.
**Kurbeler** (bis 2026-09-08 „stammtisch-hersbruck.de"; Domain und
Repository-Verzeichnis heißen weiterhin so) is a SvelteKit 5 application using Svelte 5's new runes syntax ($state, $props, etc.) with PocketBase as the backend. The app manages events, stages, riders, times and teams for a motorsport/cycling event organization.
## Die Grenze
The repository is a monorepo: the SvelteKit app lives in `frontend/`, the PocketBase instance in `backend/`.
Was diese Seite darf: über sich selbst Auskunft geben und weiterzeigen.
## Development Commands
Was sie nicht darf: irgendetwas laden, speichern oder abfragen. Kein
PocketBase, keine Anmeldung, kein Formular, keine Cookies, keine Schriften und
Karten von fremden Servern. Die Datenschutzerklärung sagt das zu, und sie ist
nur so lange wahr, wie es so bleibt. Jede Einbindung, die eine Anfrage an einen
dritten Server auslöst, macht sie zu einer falschen Auskunft über die
Rechtslage.
Alle Frontend-Befehle laufen aus `frontend/`:
## Aufbau
```bash
cd frontend
- `frontend/src/routes/+page.svelte` — der Wegweiser
- `frontend/src/routes/imprint`, `privacy` — Rechtstexte, über
`LegalPage.svelte` gerahmt
- `frontend/src/routes/+layout.ts``prerender = true` für alles
- `frontend/static/stammtisch-hersbruck.svg` — das Wappen
# Start development server (runs on http://stammtisch-hersbruck.de.localhost:31337)
npm run dev
`adapter-static`, kein Server im Betrieb. Gebaut wird mit Bun, ausgeliefert
mit nginx (`frontend/Dockerfile`).
# Build for production
npm run build
## Sprache
# Preview production build
npm run preview
Oberflächentexte und Kommentare deutsch, Bezeichner und Routenpfade englisch
(`/imprint`, nicht `/impressum`) — dieselbe Regel wie in Kurbeler.
# Type-check Svelte files
npm run check
## Das Backend
# Type-check with watch mode
npm run check:watch
`backend/` ist Bestand und wird vom Wegweiser nicht benutzt. Nicht anfassen,
ohne dass es jemand ausdrücklich will: Darin liegen die Daten des alten
Stammtisch-Deployments.
# Generate TypeScript types from the schema migration in backend/pb_migrations
# (no running instance, no token, no network access needed)
npm run generate-pocketbase-types
```
Backend (PocketBase) aus `backend/`:
```bash
cd backend
# PocketBase lokal starten (Admin-UI auf http://127.0.0.1:8090/_/)
docker compose up -d --build
# Stoppen
docker compose down
```
## Architecture
### Backend Integration (PocketBase)
- **API Base URL**: konfigurierbar über `PUBLIC_PB_URL` in `frontend/.env`.
Default ist die produktive Instanz `https://api.stammtisch-hersbruck.de`;
für das lokale Backend aus `backend/` auf `http://127.0.0.1:8090` umstellen.
- **Schema**: versioniert als Migration in `backend/pb_migrations/`, kommt per
`COPY` ins Image (kein Bind-Mount — der würde das Image-Verzeichnis
überdecken). Im Admin-UI erzeugte Migrationen liegen deshalb zunächst nur im
Container und müssen mit `docker compose cp` ins Repo geholt werden; siehe
`backend/README.md`.
- **Type-safe client**: The PocketBase client is typed using auto-generated types in `frontend/src/lib/types.d.ts`
- **Collections**: users, teams, team_invites, events, stages, riders, times,
trails, trail_versions, trail_flags, trail_markers, trail_comments,
event_participants, event_series, notification_prefs, notifications
- **Regeln**: Jede Zugriffsregel, die `@request.auth.id` erwähnt, beginnt mit
`@request.auth.id != "" && (…)`. Ohne diesen Vorspann ist eine *leere*
Relation gleich dem leeren `@request.auth.id` einer anonymen Anfrage — ein
Team ohne Admins stünde damit offen im Netz. Siehe
`backend/pb_migrations/1754501700_rules_require_login.js`.
- **Hooks**: `backend/pb_hooks/` enthält serverseitiges JS für das, was keine
Collection-Regel abbilden kann — die öffentlichen Routen der Einladungslinks,
den Uhrenabgleich, `GET /api/directory/users` (Benutzerverzeichnis des
Superadmins), `POST /api/team-invites/send` (Einladungsmail) und die
Benachrichtigungen. Jeder Route- und Event-Handler läuft in einer eigenen
JS-Laufzeit; **was neben `routerAdd`/`onRecord…` im Dateiscope steht, sieht
der Handler nicht.** Gemeinsamer Code gehört in ein Modul und wird per
``require(`${__hooks}/…`)`` im Handler geholt. Details in
`backend/README.md`.
- **Mails**: SMTP im Admin-UI; dazu `APP_URL` (Adresse des **Frontends**, für
die Links) und optional `APP_NAME`. Vorlagen liegen in
`backend/pb_hooks/views/` und werden mit `$template.loadFiles(…).render(…)`
gefüllt.
- **Benachrichtigungen**: Kein Auslöser verschickt eine Mail — alle legen
einen Eintrag in `notifications`, ein Cron-Job macht daraus **eine
Zusammenfassung pro Konto und Tag** (6 Uhr). Voreinstellungen stehen doppelt,
in `notifications_lib.js` und `notificationPrefs.svelte.ts`; beide Listen
müssen zusammenpassen.
- **Authentication**: Handled through `src/lib/stores/pocketbase.svelte.ts` with the `AuthStore` class
- **File handling**: Use `getFileURL(record, file, options)` helper for PocketBase file URLs
### State Management Pattern
The app uses Svelte 5 runes for state management with a custom store pattern:
1. **Global stores** in `src/lib/stores/`:
- `pocketbase.svelte.ts`: PocketBase client, auth, and collection operations
- `app.svelte.ts`: Global app state (theme, navigation, confirm dialogs, hotkeys)
- `teams.svelte.ts`: Example of collection-specific store pattern
2. **Collection store pattern**:
- Each collection has a context-based store (see `teams.svelte.ts` as template)
- Use `setTeamContext()` in parent and `getTeamContext()` in children
- Stores provide: `records`, `refresh()`, `create()`, `edit()`, `remove()`
- The `collections` helper in `pocketbase.svelte.ts` provides reusable CRUD operations
3. **Auth flow**:
- Auth state lives in `auth` store from `pocketbase.svelte.ts`
- Cookie-based persistence available via `auth.cookie` flag
- Auth store syncs with PocketBase `authStore.onChange()`
### UI Components
- **UI Library**: Using shadcn-svelte components (bits-ui based)
- **Component path alias**: `@/components/ui/*` maps to `$lib/components/ui/*`
- **Styling**: Tailwind CSS 4.x with custom configuration
- **Dark mode**: Handled by `mode-watcher` package, toggle with `toggleMode()`
- **Tooltips**: Global `tooltip` action available from `app.svelte.ts` using tippy.js
### Important Patterns
**Svelte 5 Runes**: This project uses Svelte 5 syntax exclusively:
- `$state()` for reactive state (not `let` with `$:`)
- `$props()` for component props
- `$derived()` for computed values
- `{@render children?.()}` for slot content
**Type Generation**: After modifying the schema migration in `backend/pb_migrations/`, run `npm run generate-pocketbase-types` to update `frontend/src/lib/types.d.ts`. The script reads the collections array straight out of the migration (`frontend/scripts/schema-to-json.mjs`) — schema and types come from the same versioned source and cannot drift apart. Commit the regenerated file.
**Async Data Loading**: Root layout (`src/routes/+layout.svelte`) shows pattern:
```svelte
{#await load()}
<!-- loading state -->
{:then _}
<!-- main content -->
{/await}
```
**Confirm Dialogs**: Use `app.confirm.request()` for user confirmations (see `events.svelte.ts` remove pattern).
**Hotkeys**: Use `app.hotkey(event, condition, callback)` which auto-ignores input fields and contenteditable elements.
**Tooltips**: Never use the `title` attribute for tooltips — appwide. `<Button>`
takes a `tooltip="…"` prop (it also becomes the `aria-label`, so icon-only
buttons keep an accessible name); every other element uses the action:
`use:tooltip={{ content: '…' }}` from `app.svelte.ts`. Both render through
tippy.js, which the action initializes, updates and destroys.
### Project Configuration
- **Repo-Struktur**: Monorepo mit `frontend/` (SvelteKit) und `backend/`
(PocketBase). Ein einziges Git-Repo im Root.
- **Dev server port**: 31337 (strict mode, custom domain: `stammtisch-hersbruck.de.localhost`)
- **Path alias**: `@/*` resolves to `./src/lib/*` (configured in frontend/svelte.config.js)
- **Adapter**: `@sveltejs/adapter-node` — Deployment läuft über Coolify mit
`node build`. `adapter-auto` erkennt Coolify nicht und erzeugt kein `build/`.
- **Node**: `engines` verlangt `^20.19 || ^22.12 || >=24`; mit
`engine-strict=true` (`.npmrc`) bricht `npm ci` sonst mit `EBADENGINE` ab.
- **Deployment-Env**: `PUBLIC_PB_URL` muss dort gesetzt sein — sie wird zur
Buildzeit eingesetzt, und die lokale `.env` ist gitignored.
### File Structure Notes
- Routes are in `src/routes/` following SvelteKit conventions. **Routennamen
immer auf Englisch** (`/invite/[token]`, nicht `/einladung/[token]`) —
Oberflächentexte sind deutsch, Pfade nicht.
- Der Menüpunkt **Team** (`/dashboard/team`) führt Team und Kader an einer
Stelle: Kennzahlen, Teamdaten, Einladungslinks und die Fahrerliste samt der
Verwaltungsknöpfe der Teamleitung. Unter **Einstellungen** steht nur noch,
was einen selbst betrifft (Profil, Benachrichtigungen, Flag-Typen) — dazu für
Superadmins der Reiter **Benutzer**, das appweite Kontoverzeichnis.
- **Konten löscht die Teamleitung nicht.** Im Kader gibt es genau einen
Eingriff an einer fremden Person: „Zugang zum Team entziehen"
(`teams.removeMember`). Fahrer, Teilnahmen und Zeiten bleiben dabei stehen —
sie sind die Geschichte des Teams, nicht die des Logins. Ein Konto löscht
allein sein Inhaber (Einstellungen → Profil) oder der Superadmin
(Einstellungen → Benutzer); `users.deleteRule` erzwingt das. Ein
„Fahrer löschen" gibt es bewusst nicht mehr.
- **Ein Fahrer, ein Name, eine Richtung**: Ein Konto entsteht immer am Fahrer
— die Teamleitung lädt per Mail ein (`POST /api/team-invites/send`), das
Passwort setzt der Eingeladene selbst. Ein Passwort im Kader zu vergeben gibt
es nicht mehr. Verknüpft wird der Fahrer beim Einlösen der Einladung; es gibt
kein Verknüpfen bestehender Konten und kein Lösen. Angezeigt wird ausschließlich der Fahrername — der Kontoname wäre
eine zweite Wahrheit, die niemand geradeziehen könnte, weil
`users.updateRule` nur den Kontoinhaber selbst ändern lässt.
- Reusable components in `src/lib/components/`
- Stores use `.svelte.ts` extension for Svelte 5 runes
- Static assets in `static/`
- `components.json` configures shadcn-svelte CLI
## When Working with This Codebase
- Always use Svelte 5 runes syntax, never legacy Svelte syntax
- Use the official Svelte MCP server to validate Svelte code
- Collection stores should follow the pattern in `teams.svelte.ts`
- PocketBase operations go through the `collections` helper for consistency
- UI components should use the shadcn-svelte imports from `@/components/ui/`

View file

@ -1,31 +0,0 @@
Die "Wanderer"-Schiene
Shredder: Ziemlich actiongeladen, 100% MTB-Jargon und hat genau wie "Wanderer" dieses aktive, ausführende Element.
Kurbler: Die direktere, etwas rotzigere deutsche Alternative. Hat einen leichten Retro-Charme.
Dropper: Angelehnt an die Dropper-Post (absenkbare Sattelstütze) oder das "Reindroppen" in den Trail.
Fokus auf Trail & Community
TrailTribe: Betont den Vereins- und Gruppenaspekt hervorragend.
DirtBase: Perfekt für die Traildatenbank kurz, prägnant und technisch sauber.
FlowHub: Verbindet das ultimative MTB-Gefühl ("Flow") mit dem modernen Plattform-Gedanken für Eventveranstalter.
Fokus auf Zeitnahme & Action
SendSync: "Send it!" gepaart mit der schnellen Datensynchronisation einer Webapp.
PacePeak: Klingt sportlich, kompetitiv und extrem passend für Rennveranstalter und Bestzeitenjäger.
TrailTicker: Etwas verspielter, bringt aber das Thema Zeitmessung und Live-Updates sofort auf den Punkt.
Kurz & Knackig (Startup-Vibe)
Berm: Englisch für die Anliegerkurve. Kurz, international und unter Mountainbikern ein fester Begriff.
Roost: Der aufgeworfene Dreck in der Kurve. Hat Energie und ist sehr spezifisch für den Offroad-Sport.
Stollen: Ein sehr deutsches, geerdetes Wort (Stollenreifen), das MTB-Flair mit technischer Präzision verbindet.

View file

@ -1,69 +1,56 @@
# stammtisch-hersbruck.de
# stammtisch-hersbruck.de — abgelöst
Ein Wegweiser. Mehr ist diese Adresse nicht mehr.
> **Dieses Repository wird nicht weiterentwickelt.**
>
> Die App heißt seit dem 08.09.2026 **Kurbeler** und wird unter
> `~/Dokumente/Projekte/kurbeler` fortgeführt. Dort liegt die vollständige
> Historie dieses Repos — es ist kein Neuanfang, sondern dieselbe Geschichte
> unter neuem Namen.
>
> Der Stammtisch Hersbruck bekommt **kein** eigenes Deployment mehr, sondern
> ist ein Team in Kurbeler. Die PocketBase-Instanz hinter
> `api.stammtisch-hersbruck.de` bleibt dieselbe und wird nur umbenannt — es
> zieht kein einziger Datensatz um.
Bis September 2026 lag hier eine Anwendung: Zeitnahme, Kader, Trails,
Renntage. Die heißt seit dem 08.09.2026 **Kurbeler**, liegt unter
[kurbeler.de](https://kurbeler.de) und wird in
`~/Dokumente/Projekte/kurbeler` fortgeführt — mit der vollständigen Historie
dieses Repos. Es war kein Neuanfang, sondern dieselbe Geschichte unter neuem
Namen, und der Stammtisch Hersbruck ist dort ein Team unter mehreren.
Was hier blieb, ist die Adresse. Wer ein altes Lesezeichen anklickt oder einen
Link aus einer Gruppe von damals, soll nicht ins Leere laufen, sondern in
einem Blick sehen, wo es weitergeht.
Zeitmessung und Verwaltung für Läufe des Stammtisch Hersbruck.
## Die Seite
Die Anwendung heißt seit dem 08.09.2026 **Kurbeler**. Domain, Repository und
Verzeichnisnamen bleiben `stammtisch-hersbruck.de` — umbenannt wurde der Name,
unter dem die App auftritt, nicht ihre Adresse.
Drei vorgerenderte HTML-Seiten in `frontend/`:
Das Repository enthält beide Teile der Anwendung:
| Adresse | Inhalt |
| Verzeichnis | Inhalt |
|---|---|
| `/` | Wappen, ein Satz, ein Knopf nach kurbeler.de |
| `/imprint` | Impressum nach § 5 DDG |
| `/privacy` | Datenschutz — kurz, weil hier nichts passiert |
| [`frontend/`](frontend/) | SvelteKit-5-Anwendung (Svelte 5 Runes, Tailwind 4) |
| [`backend/`](backend/) | PocketBase-Instanz — Dockerfile, Schema-Migrationen |
Kein Konto, keine Datenbank, kein Formular, keine Cookies, keine Schriften
oder Karten von fremden Servern. `adapter-static` erzeugt beim Bauen fertiges
HTML, nginx liefert es aus. Das ist auch der Grund, warum eine Link-Vorschau
in WhatsApp den Text sieht: Die führt kein JavaScript aus.
## Schnellstart
## Befehle
Frontend gegen die produktive Instanz:
Aus `frontend/`, Paketverwalter ist Bun:
cd frontend
cp .env.example .env # Werte eintragen
npm ci
npm run dev
```bash
bun install
bun run dev # http://localhost:5173
bun run build # nach build/
bun run check # Typprüfung
```
Die App läuft dann auf http://stammtisch-hersbruck.de.localhost:31337
## Deployment
Backend lokal (optional — der Default zeigt auf die produktive Instanz):
Eigenes Bild, wie bei Kurbeler: `frontend/Dockerfile` baut mit `oven/bun`
(Version gepinnt) und liefert mit `nginx:alpine` aus;
`frontend/docker-compose.yaml` ist das, was Coolify liest. Build Pack **Docker
Compose**, Base Directory `/frontend`, Compose-Datei `/docker-compose.yaml`.
cd backend
cp .env.example .env # Werte eintragen
docker compose up -d --build
> **Die Domain gehört dem Dienst, nicht der App.** Bei Build Pack Docker
> Compose hängt sie an `docker_compose_domains` und nicht mehr an `fqdn`.
> Bleibt das Feld leer, läuft der Container gesund und Traefik hat trotzdem
> keine Route — von außen sieht das aus wie ein kaputter Build. Derselbe
> Stolperstein wie bei Kurbeler, dort steht er ausführlicher.
Danach in `frontend/.env` `PUBLIC_PB_URL=http://127.0.0.1:8090` setzen.
## Das Backend
Details stehen in [`backend/README.md`](backend/README.md).
`backend/` liegt noch im Repo und die PocketBase-Instanz hinter
`api.stammtisch-hersbruck.de` läuft weiter. Der Wegweiser spricht sie nicht an
— er spricht mit gar nichts. Ob die Instanz bleibt, ist offen; abgeschaltet
wird sie nicht nebenbei, weil die Daten des alten Stammtisch-Deployments darin
liegen.
## PocketBase
## Was sonst noch im Repo liegt
Produktiv: https://api.stammtisch-hersbruck.de
Der Zweig `abgebrochener-umbau` hält den Stand fest, in dem hier mit der
Umbenennung zu Kurbeler angefangen wurde, bevor sie ins eigene Repo zog. Er
wird nicht weiterentwickelt und ist nur da, damit die Geschichte nachlesbar
bleibt — unter anderem enthält er das Stammtisch-Wappen, das nur dort je
entstanden ist.
Welche Instanz das Frontend anspricht, entscheidet `PUBLIC_PB_URL` in
`frontend/.env`. Das Schema ist als Migration in `backend/pb_migrations/`
versioniert.

0
TODO.md Normal file
View file

BIN
UML_basic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View file

@ -26,3 +26,18 @@ SUPERUSER_PASSWORD=bitte-aendern-min-8-zeichen
# Optional: überschreibt den in docker-compose.yaml gepinnten Default.
# Nur setzen, wenn bewusst eine andere PocketBase-Version gebaut werden soll.
#PB_VERSION=0.39.11
# Adresse des Frontends, ohne Schrägstrich am Ende. Aus ihr baut PocketBase
# die Links in Einladungs- und Benachrichtigungsmails.
#
# Wichtig: Das ist NICHT die Adresse von PocketBase. Ein Einladungslink auf
# die API führte ins Leere — er muss auf die Seite zeigen, die das Konto
# anlegt. Ohne diese Variable verweigern die Mail-Routen den Versand, statt
# tote Links zu verschicken.
#
# Lokal: http://stammtisch-hersbruck.de.localhost:31337
APP_URL=https://stammtisch-hersbruck.de
# Optional: Name der Anwendung in Betreff und Mailtext. Ohne die Variable
# steht dort "Kurbeler". Nur setzen, wenn eine Instanz sich abheben soll.
#APP_NAME=Kurbeler (Test)

View file

@ -1,4 +1,4 @@
# stammtisch-hersbruck.de — Backend (PocketBase)
# Kurbeler — Backend (PocketBase)
PocketBase-Instanz für Events, Läufe, Fahrer, Zeiten und Teams.
@ -105,12 +105,121 @@ Serverseitiges JavaScript, das PocketBase beim Start lädt (`--hooksDir`). Wie
`pb_migrations/` kommt das Verzeichnis per `COPY` ins Image — eine Änderung
wirkt also erst nach `docker compose up -d --build`.
Bisher liegt dort ein Anliegen: die öffentlichen Routen der Einladungslinks.
| Datei | Zweck |
|---|---|
| `clock.pb.js` | Uhrenabgleich für die Zeitnahme |
| `team_invites.pb.js` | `GET /api/team-invites/{token}`, `POST …/accept`, `POST …/join` |
| `team_invites_lib.js` | Gemeinsame Helfer der drei Routen |
| `team_invites_lib.js` | Gemeinsame Helfer der drei Einladungs-Routen |
| `directory.pb.js` | `GET /api/directory/users` — Benutzerverzeichnis des Superadmins |
| `directory_lib.js` | Helfer dazu |
| `mail_lib.js` | Absender, Vorlagen, Versand — alles, was Mails verschickt, geht hier durch |
| `notifications.pb.js` | Auslöser der Benachrichtigungen und der tägliche Versand |
| `notifications_lib.js` | Voreinstellungen, Empfängerkreis, Warteschlange |
| `views/invite.html` | Vorlage der Einladungsmail |
| `views/digest.html` | Vorlage der Tageszusammenfassung |
Vorlagen werden mit `$template.loadFiles(...).render(data)` gefüllt — Go's
`html/template`. Die Platzhalter stehen deshalb in `{{ }}` und werden dabei
escaped; Trailnamen und Kommentartexte kommen aus der Datenbank und dürfen
kein Markup einschleusen.
## Mails
SMTP wird im Admin-UI eingerichtet (Settings → Mail settings). Zwei
Umgebungsvariablen kommen dazu, beide in `.env.example` beschrieben:
| Variable | Wofür |
|---|---|
| `APP_URL` | Adresse des **Frontends**. Aus ihr entstehen die Links in Mails. |
| `APP_NAME` | Optional. Name in Betreff und Text; ohne sie steht dort „Kurbeler". |
`APP_URL` ist ausdrücklich nicht `settings().meta.appURL` — die zeigt auf
PocketBase selbst, und ein Einladungslink dorthin führte auf die API statt auf
die Seite, die das Konto anlegt. Sie ist auch nicht der `Origin` des
Aufrufers: Der steht unter dessen Kontrolle, und ein Link in einer Mail, den
ein Fremder auf seinen eigenen Server zeigen lassen kann, ist eine
Phishing-Vorlage mit unserem Absender darauf. Fehlt die Variable, verweigern
die Mail-Routen den Dienst.
### Einladung statt Passwort
`POST /api/team-invites/send` ist der Ersatz für „Konto anlegen" im Kader.
Dort tippte die Teamleitung Adresse **und Passwort** ein und musste dieses
Passwort anschließend irgendwie zum Fahrer bringen — über einen Kanal, den
niemand geplant hat. Sie kannte es außerdem.
Jetzt legt die Route eine persönliche Einladung an (`team_invites` mit
`email` und `rider`) und verschickt sie. Das Passwort entsteht erst beim
Empfänger auf `/invite/{token}`. Die Adresse ist dabei gebunden: Das Feld auf
der Seite ist gesperrt, und weil ein gesperrtes Feld nur eine Bitte ist, prüft
`/accept` sie noch einmal.
Die allgemeinen **Einladungslinks bleiben unverändert** — sie gelten jedem,
der sie hat, und lassen `email` und `rider` leer.
### Benachrichtigungen: eine Mail am Tag
Kein Auslöser verschickt eine Mail. Alle legen einen Eintrag in
`notifications`, und `cronAdd('notifications_digest', '0 6 * * *', …)` macht
daraus einmal täglich je Konto eine Zusammenfassung.
Das ist der ganze Punkt: Ein Trailkommentar, dem drei Antworten folgen, löste
sonst vier Mails aus. Ein Verein, der sich die nicht leisten kann, schaltet
nach drei Tagen alles ab.
Nebenwirkung, die zählt: Ein Fehler im Mailversand kann nie das Speichern
eines Events verhindern — der Auslöser schreibt nur in eine Tabelle.
Scheitert der Versand an ein Konto, bleiben dessen Einträge unversendet stehen
und sind am nächsten Morgen wieder dabei.
Was ohne Einstellungen gilt, steht als `DEFAULTS` in `notifications_lib.js`
und noch einmal in `frontend/src/lib/stores/notificationPrefs.svelte.ts`.
**Beide Listen müssen zusammenpassen** — stünde dort etwas anderes, zeigte die
Oberfläche einen Schalter an, der nichts mit dem zu tun hat, was verschickt
wird. Die Linie: An heißt „betrifft mich", aus heißt „ist viel".
`notifications` ist über die API nicht beschreibbar (create/update/delete auf
`null`, in PocketBase „nur Superuser"). Gefüllt wird sie ausschließlich aus
den Hooks, die an den Regeln vorbeilaufen — sonst schriebe sich jeder selbst
Meldungen, oder anderen.
### Warum das Verzeichnis eine Route braucht
Konten entstehen mit `emailVisibility: false` (`teams.svelte.ts`,
`createMember`). PocketBase gibt die E-Mail-Adresse damit nur ihrem Inhaber
heraus — in jedem `users`-Datensatz, den jemand anderes liest, ist das Feld
leer. Ein Verzeichnis ohne Adressen wäre aber eine Liste von Namen, in der
niemand ein Konto wiedererkennt.
Das Flag umzulegen wäre eine Zeile und zugleich ein Loch: `users.listRule`
gilt appweit (`@request.auth.id != ""`), jede angemeldete Person läse danach
jede Adresse — quer über alle Teams. Der Hook liest sie stattdessen
serverseitig und gibt sie nur dem heraus, der sie sehen darf.
`superadmin` wird dabei frisch aus der Datenbank gelesen und nicht aus dem
mitgeschickten Auth-Record: Der Token hält den Stand von der Anmeldung fest,
eine entzogene Rolle wirkte sonst bis zu seinem Ablauf nicht.
## Wer darf ein Konto löschen?
Zwei, und sonst niemand — `users.deleteRule` (siehe
`1757340000_users_superadmin_delete.js`):
| Wer | Wo |
|---|---|
| Der Inhaber selbst | Einstellungen → Profil |
| Superadmin | Einstellungen → Benutzer |
Die Teamleitung ausdrücklich **nicht**. Sie verwaltet Fahrer und
Mitgliedschaften; im Kader gibt es dafür genau einen Eingriff an einer fremden
Person, „Zugang zum Team entziehen" (`teams.removeMember`). Der nimmt das Konto
aus `teams.users`/`teams.admins` — Fahrer, Teilnahmen und Zeiten bleiben
stehen. Sie sind die Geschichte des Teams und nicht die des Logins.
Ein Konto, das ein Team besitzt, lässt sich nicht löschen: `teams.owner` ist
eine Pflichtrelation ohne cascadeDelete, PocketBase müsste sie beim Löschen
leeren und bräche dabei den Datensatz. Beide Oberflächen prüfen das vorher und
sagen, welches Team im Weg steht — sonst stünde dort ein Datenbankfehler.
Warum überhaupt ein Hook? Wer über einen Link beitritt, ist noch nicht
angemeldet und muss drei Dinge auf einmal tun, die keine Collection-Regel

View file

@ -29,6 +29,15 @@ services:
# Superuser wird nur beim allerersten Start angelegt (siehe entrypoint.sh)
SUPERUSER_EMAIL: ${SUPERUSER_EMAIL:-}
SUPERUSER_PASSWORD: ${SUPERUSER_PASSWORD:-}
# Adresse des Frontends. Aus ihr entstehen die Links in Mails —
# Einladung und Tageszusammenfassung. NICHT die Adresse von PocketBase:
# Ein Link dorthin führte auf die API statt auf die Seite. Fehlt die
# Variable, verweigern die Mail-Routen den Dienst, statt tote Links zu
# verschicken.
APP_URL: ${APP_URL:-}
# Name der Anwendung in Betreff und Mailtext. Nur setzen, wenn eine
# Instanz sich abheben soll ("Kurbeler (Test)").
APP_NAME: ${APP_NAME:-}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8090/api/health"]
interval: 30s

View file

@ -0,0 +1,74 @@
/// <reference path="../pb_data/types.d.ts" />
// Das Benutzerverzeichnis des Superadmins.
//
// GET /api/directory/users — alle Konten der App, nur Superadmin
//
// Warum eine eigene Route und keine Collection-Regel? Wegen der E-Mail.
// Konten entstehen mit `emailVisibility: false`, PocketBase gibt die Adresse
// damit nur ihrem Inhaber heraus — und ein Verzeichnis ohne Adressen wäre
// eine Liste von Namen, in der niemand ein Konto wiedererkennt. Das Flag
// umzulegen wäre eine Zeile und zugleich ein Loch: `users.listRule` gilt
// appweit (`@request.auth.id != ""`), jede angemeldete Person läse danach
// jede Adresse, quer über alle Teams.
//
// Der Hook liest sie stattdessen serverseitig und gibt sie nur dem einen
// heraus, der sie sehen darf.
//
// Jeder Handler holt sich seine Helfer selbst per require(): PocketBase führt
// Route-Handler in einer eigenen JS-Laufzeit aus, der Dateikopf hier ist dort
// nicht sichtbar.
// --- Alle Konten der App ----------------------------------------------------
//
// Das Verzeichnis des Superadmins. Es führt Konten, nicht Fahrer: Ein Fahrer
// gehört einem Team und wird dort verwaltet, ein Konto gehört niemandem außer
// sich selbst — und genau deshalb kann es auch nur hier fallen.
//
// `owns` sagt, welche Teams das Konto besitzt. Die Oberfläche braucht das,
// bevor sie den Löschknopf anbietet: `teams.owner` ist eine Pflichtrelation
// ohne cascadeDelete, ein Löschen liefe sonst in einen Datenbankfehler statt
// in einen Satz, den jemand lesen kann.
routerAdd('GET', '/api/directory/users', (e) => {
const lib = require(`${__hooks}/directory_lib.js`)
const user = e.auth
if (!user || !lib.isSuperadmin(e.app, user)) {
throw new ForbiddenError('Nur für Superadmins.')
}
const teams = lib.allTeams(e.app)
const records = e.app.findAllRecords(lib.USERS)
const out = []
for (let i = 0; i < records.length; i++) {
const u = records[i]
const memberOf = []
const owns = []
for (let t = 0; t < teams.length; t++) {
const team = teams[t]
if (lib.ownerId(team) === u.id) {
owns.push(team.getString('name'))
}
if (lib.isMember(team, u.id)) {
memberOf.push(team.getString('name'))
}
}
out.push({
id: u.id,
name: u.getString('name'),
email: u.getString('email'),
verified: u.getBool('verified'),
superadmin: u.getBool('superadmin'),
created: u.getString('created'),
avatar: u.getString('avatar'),
teams: memberOf,
owns: owns,
})
}
return e.json(200, { users: out })
}, $apis.requireAuth())

View file

@ -0,0 +1,78 @@
/// <reference path="../pb_data/types.d.ts" />
// Gemeinsamer Code der Verzeichnis-Route.
//
// Eigene Datei aus demselben Grund wie bei den Einladungen: PocketBase führt
// jeden Route-Handler in einer eigenen JS-Laufzeit aus. Was neben routerAdd
// steht, sieht der Handler nicht — er holt sich seine Helfer per require().
const USERS = 'users'
const TEAMS = 'teams'
/**
* Die IDs einer Relation als gewöhnliches Array.
*
* `getStringSlice` und nicht `get`: Es liefert bei Einzel- wie
* Mehrfachrelation dieselbe Form und kommt auch mit dem leeren Feld zurecht.
* `get` gäbe je nach Feld einen String oder eine Go-Slice zurück zwei
* Formen, die der Aufrufer auseinanderhalten müsste.
*/
function idsOf(record, field) {
const raw = record.getStringSlice(field) || []
const out = []
for (let i = 0; i < raw.length; i++) {
if (raw[i]) {
out.push(raw[i])
}
}
return out
}
/** Wem gehört das Team? Leerer String, wenn der Owner fehlt. */
function ownerId(team) {
const ids = idsOf(team, 'owner')
return ids.length > 0 ? ids[0] : ''
}
/** Ist die Person Mitglied (oder Owner) dieses Teams? */
function isMember(team, userId) {
if (ownerId(team) === userId) {
return true
}
return idsOf(team, 'users').indexOf(userId) !== -1
}
/**
* Superadmin ist eine appweite Rolle auf dem eigenen users-Datensatz, kein
* PocketBase-Superuser. Gelesen wird sie frisch aus der Datenbank und nicht
* aus dem mitgeschickten Auth-Record: Der Token hält den Stand von der
* Anmeldung fest, eine entzogene Rolle wirkte sonst bis zum Ablauf nicht.
*/
function isSuperadmin(app, user) {
if (!user) {
return false
}
try {
return app.findRecordById(USERS, user.id).getBool('superadmin')
} catch (err) {
return false
}
}
/** Alle Teams — für die Zuordnung im Superadmin-Verzeichnis. */
function allTeams(app) {
return app.findAllRecords(TEAMS)
}
module.exports = {
USERS: USERS,
idsOf: idsOf,
ownerId: ownerId,
isMember: isMember,
isSuperadmin: isSuperadmin,
allTeams: allTeams,
}

View file

@ -0,0 +1,112 @@
/// <reference path="../pb_data/types.d.ts" />
// Alles, was in dieser Anwendung eine Mail verschickt, geht hier durch.
//
// Eigene Datei, weil PocketBase jeden Route- und Event-Handler in einer
// eigenen JS-Laufzeit ausführt: Was neben routerAdd steht, sieht der Handler
// nicht. Er holt sich seine Helfer per require().
//
// Drei Dinge stehen hier, die sonst in jeder Mail neu erfunden würden:
// woher der Name der App kommt, woher die Adresse des Frontends kommt, und
// wie aus einer Vorlage samt Daten eine verschickte Mail wird.
const DEFAULT_APP_NAME = 'Kurbeler'
/**
* Name der Anwendung, wie er in Mails auftaucht.
*
* Über `APP_NAME` überschreibbar eine Testinstanz soll in der Betreffzeile
* erkennbar sein, ohne dass jemand den Code anfasst.
*/
function appName() {
return $os.getenv('APP_NAME') || DEFAULT_APP_NAME
}
/**
* Adresse des Frontends, ohne Schrägstrich am Ende.
*
* Das ist NICHT `settings().meta.appURL`: Die zeigt auf PocketBase selbst,
* und ein Einladungslink dorthin führte auf die API statt auf die Seite, die
* das Konto anlegt.
*
* Und es ist ausdrücklich nicht der `Origin`-Header des Aufrufers. Der steht
* unter der Kontrolle dessen, der die Anfrage stellt ein Link in einer
* Mail, den ein Fremder auf seinen eigenen Server zeigen lassen kann, ist
* eine Phishing-Vorlage mit unserem Absender darauf.
*
* Fehlt die Variable, gibt es keine Mail. Lieber ein Fehler, den die
* Teamleitung sieht, als ein Link ins Leere, den sie nie zu Gesicht bekommt.
*/
function appURL() {
const raw = ($os.getenv('APP_URL') || '').trim()
if (!raw) {
return ''
}
return raw.replace(/\/+$/, '')
}
/** Eine Vorlage aus pb_hooks/views/ mit Daten füllen. */
function render(view, data) {
return $template.loadFiles(`${__hooks}/views/${view}`).render(data)
}
/**
* Absender aus den PocketBase-Einstellungen. Dort wird auch SMTP eingerichtet;
* beides gehört ins Admin-UI und nicht in den Code.
*/
function sender(app) {
const meta = app.settings().meta
return {
address: meta.senderAddress,
name: meta.senderName || appName(),
}
}
/**
* Mail verschicken. Wirft weiter, wenn der Versand scheitert ob das den
* Aufrufer scheitern lässt, entscheidet er selbst: Eine Einladung ohne Mail
* ist wertlos und muss scheitern, eine Benachrichtigung darf im Stillen
* verloren gehen, statt das Speichern eines Events zu verhindern.
*/
function send(app, to, subject, html) {
const message = new MailerMessage({
from: sender(app),
to: [{ address: to.address, name: to.name || '' }],
subject: subject,
html: html,
})
app.newMailClient().send(message)
}
/** Vorlage rendern und verschicken — der übliche Weg. */
function sendView(app, to, subject, view, data) {
send(app, to, subject, render(view, data))
}
/**
* Ein Datum, wie es in einer Mail stehen soll: 30.09.2026".
*
* PocketBase liefert 2026-09-30 10:00:00.000Z"; das direkt hineinzuschreiben
* sähe aus wie ein Datenbankfeld und nicht wie ein Satz.
*/
function formatDay(value) {
const day = (value || '').slice(0, 10)
if (day.length !== 10) {
return ''
}
const parts = day.split('-')
return `${parts[2]}.${parts[1]}.${parts[0]}`
}
module.exports = {
appName: appName,
appURL: appURL,
render: render,
send: send,
sendView: sendView,
formatDay: formatDay,
}

View file

@ -0,0 +1,326 @@
/// <reference path="../pb_data/types.d.ts" />
// Die Auslöser und der tägliche Versand.
//
// Alle Auslöser tun dasselbe: Sie legen einen Eintrag in `notifications` und
// sind fertig. Keiner verschickt eine Mail. Das ist der Punkt — sonst löste
// ein Kommentar, dem drei Antworten folgen, vier Mails aus, und ein Verein,
// der sich die nicht leisten kann, schaltet nach drei Tagen alles ab.
//
// Einmal am Tag nimmt der Cron-Job unten alles Unversendete, macht je Konto
// eine Mail daraus und hakt es ab.
//
// Alle Hooks hängen an `...AfterCreateSuccess` bzw. `...AfterUpdateSuccess`:
// Erst wenn der Datensatz wirklich steht, gibt es etwas zu melden. Und keiner
// darf werfen — ein Event anzulegen darf nicht daran scheitern, dass ein
// Hinweis nicht geschrieben werden konnte. Die Bibliothek fängt deshalb
// selbst ab; hier steht zusätzlich um jeden Handler ein try.
// --- Neues Event im Team ----------------------------------------------------
onRecordAfterCreateSuccess((e) => {
try {
const lib = require(`${__hooks}/notifications_lib.js`)
const name = e.record.getString('name') || e.record.getString('description') || 'Event'
const starts = (e.record.getString('starts') || '').slice(0, 10)
lib.queueForTeam(e.app, e.record.getString('team'), {
type: 'event_created',
actor: e.record.getString('created_by'),
title: name,
body: starts ? `Termin: ${starts.split('-').reverse().join('.')}` : 'Noch ohne Termin',
link: `/dashboard/events/${e.record.id}`,
})
} catch (err) {
console.log('[notifications] event_created:', err)
}
e.next()
}, 'events')
// --- Termin oder Ort eines Events verschoben --------------------------------
//
// Nur diese beiden Felder. Ein umbenanntes Event oder ein geänderter
// Beschreibungstext ist keine Nachricht wert; ein verschobener Termin schon —
// danach richtet sich, wer am Samstag wo steht.
onRecordAfterUpdateSuccess((e) => {
try {
const lib = require(`${__hooks}/notifications_lib.js`)
const before = e.record.original()
const movedDate = before.getString('starts') !== e.record.getString('starts')
const movedPlace = before.getString('location') !== e.record.getString('location')
if (!movedDate && !movedPlace) {
e.next()
return
}
const name = e.record.getString('name') || e.record.getString('description') || 'Event'
const starts = (e.record.getString('starts') || '').slice(0, 10)
const parts = []
if (movedDate) {
parts.push(starts ? `neuer Termin ${starts.split('-').reverse().join('.')}` : 'Termin entfernt')
}
if (movedPlace) {
parts.push(`Ort: ${e.record.getString('location') || 'offen'}`)
}
lib.queueForTeam(e.app, e.record.getString('team'), {
type: 'event_changed',
title: name,
body: parts.join(', '),
link: `/dashboard/events/${e.record.id}`,
})
} catch (err) {
console.log('[notifications] event_changed:', err)
}
e.next()
}, 'events')
// --- Zu einem Event eingetragen ---------------------------------------------
//
// Das Gegenstück zu den Teammeldungen: Diese geht an genau einen, und zwar
// den, um den es geht. Ein Fahrer ohne Konto bekommt nichts — es gibt keine
// Adresse.
onRecordAfterCreateSuccess((e) => {
try {
const lib = require(`${__hooks}/notifications_lib.js`)
const userId = lib.userOfRider(e.app, e.record.getString('rider'))
if (!userId) {
e.next()
return
}
const event = e.app.findRecordById('events', e.record.getString('event'))
const name = event.getString('name') || event.getString('description') || 'Event'
const starts = (event.getString('starts') || '').slice(0, 10)
lib.queueFor(e.app, userId, {
type: 'event_participation',
actor: e.record.getString('created_by'),
team: event.getString('team'),
title: `Du stehst bei „${name}" auf der Liste`,
body: starts ? `Termin: ${starts.split('-').reverse().join('.')}` : '',
link: `/dashboard/events/${event.id}`,
})
} catch (err) {
console.log('[notifications] event_participation:', err)
}
e.next()
}, 'event_participants')
// --- Neuer Kommentar an einem Trail -----------------------------------------
onRecordAfterCreateSuccess((e) => {
try {
const lib = require(`${__hooks}/notifications_lib.js`)
const trail = e.app.findRecordById('trails', e.record.getString('trail'))
const author = lib.nameOf(e.app, e.record.getString('created_by'))
const text = e.record.getString('text') || ''
lib.queueForTeam(e.app, e.record.getString('team'), {
type: 'trail_comment',
actor: e.record.getString('created_by'),
title: `${author} zu „${trail.getString('name') || 'Trail'}"`,
// Gekürzt: Die Zusammenfassung ist eine Übersicht und nicht der
// Ort, an dem man Kommentare liest.
body: text.length > 140 ? text.slice(0, 140) + ' …' : text,
link: `/dashboard/trails/${trail.id}`,
})
} catch (err) {
console.log('[notifications] trail_comment:', err)
}
e.next()
}, 'trail_comments')
// --- Neue Meldung an der Strecke --------------------------------------------
onRecordAfterCreateSuccess((e) => {
try {
const lib = require(`${__hooks}/notifications_lib.js`)
// Erledigte Meldungen entstehen nicht als Meldung — sie werden es.
if (e.record.getBool('resolved')) {
e.next()
return
}
const trail = e.app.findRecordById('trails', e.record.getString('trail'))
const author = lib.nameOf(e.app, e.record.getString('created_by'))
lib.queueForTeam(e.app, e.record.getString('team'), {
type: 'trail_marker',
actor: e.record.getString('created_by'),
title: `${e.record.getString('label') || 'Meldung'} an „${trail.getString('name') || 'Trail'}"`,
body: `${author}: ${e.record.getString('note') || 'ohne Notiz'}`,
link: `/dashboard/trails/${trail.id}`,
})
} catch (err) {
console.log('[notifications] trail_marker:', err)
}
e.next()
}, 'trail_markers')
// --- Trail gesperrt oder wieder frei ----------------------------------------
//
// Die eine Trail-Meldung, die standardmäßig an ist: Ob eine Strecke gesperrt
// ist, entscheidet, ob jemand am Sonntag hinfährt.
onRecordAfterUpdateSuccess((e) => {
try {
const lib = require(`${__hooks}/notifications_lib.js`)
const before = e.record.original()
const status = e.record.getString('status')
if (before.getString('status') === status) {
e.next()
return
}
lib.queueForTeam(e.app, e.record.getString('team'), {
type: 'trail_status',
actor: e.record.getString('status_by'),
title: `${e.record.getString('name') || 'Trail'}" ist jetzt: ${status}`,
body: `Geändert von ${lib.nameOf(e.app, e.record.getString('status_by'))}`,
link: `/dashboard/trails/${e.record.id}`,
})
} catch (err) {
console.log('[notifications] trail_status:', err)
}
e.next()
}, 'trails')
// --- Neuer Fahrer im Kader --------------------------------------------------
onRecordAfterCreateSuccess((e) => {
try {
const lib = require(`${__hooks}/notifications_lib.js`)
lib.queueForTeam(e.app, e.record.getString('team'), {
type: 'team_member',
title: `${e.record.getString('name') || 'Jemand'} ist neu im Kader`,
body: '',
link: '/dashboard/team',
})
} catch (err) {
console.log('[notifications] team_member:', err)
}
e.next()
}, 'riders')
// --- Der tägliche Versand ---------------------------------------------------
//
// Einmal am Morgen: alles Unversendete einsammeln, je Konto eine Mail, fertig.
//
// Warum 6 Uhr? Weil die Mail vor dem Frühstück da sein soll und nicht
// mittendrin. Die Zeitzone ist die des Containers (TZ), nicht die des
// Empfängers — für einen Verein aus einer Gegend ist das dasselbe.
//
// Der Job ist absichtlich stumpf: Er fragt nach allem ohne `sent`, gruppiert
// nach Konto und hakt nur ab, was auch wirklich rausging. Scheitert der
// Versand an ein Konto, bleiben dessen Einträge stehen und sind morgen wieder
// dabei — eine verspätete Zusammenfassung ist besser als eine verlorene.
cronAdd('notifications_digest', '0 6 * * *', () => {
const lib = require(`${__hooks}/notifications_lib.js`)
const mail = require(`${__hooks}/mail_lib.js`)
const base = mail.appURL()
if (!base) {
console.log('[notifications] APP_URL fehlt — keine Zusammenfassung verschickt.')
return
}
let pending
try {
// `sent = ""` trifft in PocketBase auch NULL — leer und nicht
// gesetzt sind im Filter dasselbe.
pending = $app.findRecordsByFilter(lib.QUEUE, 'sent = ""', '+created', 2000, 0)
} catch (err) {
console.log('[notifications] Warteschlange nicht lesbar:', err)
return
}
if (!pending || pending.length === 0) {
return
}
// Nach Konto bündeln. Ein Konto, eine Mail — darum geht die ganze Übung.
const byUser = {}
for (let i = 0; i < pending.length; i++) {
const uid = pending[i].getString('user')
if (!byUser[uid]) {
byUser[uid] = []
}
byUser[uid].push(pending[i])
}
for (const uid in byUser) {
const items = byUser[uid]
let user
try {
user = $app.findRecordById('users', uid)
} catch (err) {
// Konto gelöscht, Einträge noch da. Abhaken, sonst laufen sie
// jede Nacht aufs Neue in denselben Fehler.
lib.markSent($app, items)
continue
}
const address = user.getString('email')
if (!address) {
lib.markSent($app, items)
continue
}
// Nach Art gruppieren, damit die Mail Abschnitte hat und nicht eine
// Liste aus zwanzig gleich aussehenden Zeilen.
const groups = []
const seen = {}
for (let i = 0; i < items.length; i++) {
const type = items[i].getString('type')
if (!seen[type]) {
seen[type] = { Label: lib.LABELS[type] || type, Items: [] }
groups.push(seen[type])
}
seen[type].Items.push({
Title: items[i].getString('title'),
Body: items[i].getString('body'),
Link: items[i].getString('link') ? base + items[i].getString('link') : '',
})
}
try {
mail.sendView(
$app,
{ address: address, name: user.getString('name') },
`${mail.appName()}: ${items.length} Neuigkeit${items.length === 1 ? '' : 'en'}`,
'digest.html',
{
App: mail.appName(),
Name: user.getString('name') || 'Radlfahrer',
Count: items.length,
Groups: groups,
SettingsLink: `${base}/dashboard/settings/notifications`,
},
)
lib.markSent($app, items)
} catch (err) {
// Stehen lassen. Morgen früh noch einmal.
console.log('[notifications] Versand an', address, 'gescheitert:', err)
}
}
})

View file

@ -0,0 +1,221 @@
/// <reference path="../pb_data/types.d.ts" />
// Wer erfährt wovon — und was davon in die Warteschlange kommt.
//
// Eigene Datei, weil PocketBase jeden Hook in einer eigenen JS-Laufzeit
// ausführt: Was neben onRecordAfterCreateSuccess steht, sieht der nächste
// Handler nicht. Er holt sich seine Helfer per require().
const PREFS = 'notification_prefs'
const QUEUE = 'notifications'
/**
* Was gilt, solange niemand etwas eingestellt hat.
*
* Die Linie ist bewusst gezogen: An heißt betrifft mich", aus heißt ist
* viel". Wer zu einem Event eingetragen wird, will das wissen das passiert
* ihm. Jeder Kommentar an jedem Trail des Teams passiert dagegen ständig und
* meistens anderen; wer das will, schaltet es ein.
*
* Diese Voreinstellungen gelten auch dann, wenn ein Konto nie in die
* Einstellungen geschaut hat: Ein Datensatz in notification_prefs entsteht
* erst beim ersten Speichern.
*/
const DEFAULTS = {
enabled: true,
event_created: true, // neues Event im Team
event_participation: true, // du wurdest eingetragen, dein Status änderte sich
event_changed: true, // Termin oder Ort eines Events verschoben
trail_comment: false, // jeder Kommentar an jedem Trail
trail_marker: false, // jede neue Meldung an der Strecke
trail_status: true, // ein Trail wurde gesperrt oder wieder frei
team_member: false, // jemand ist ins Team gekommen
}
/** Überschriften der Arten, wie sie in der Zusammenfassung stehen. */
const LABELS = {
event_created: 'Neue Events',
event_participation: 'Deine Teilnahmen',
event_changed: 'Geänderte Events',
trail_comment: 'Kommentare',
trail_marker: 'Meldungen an der Strecke',
trail_status: 'Trail-Status',
team_member: 'Neu im Team',
}
/**
* Einstellungen eines Kontos, ergänzt um alles, was nicht drinsteht.
*
* Ein fehlender Datensatz ist kein Sonderfall, sondern der Normalfall bei
* jedem, der die Einstellungen nie geöffnet hat er bekommt DEFAULTS.
*/
function prefsOf(app, userId) {
let record = null
try {
record = app.findFirstRecordByFilter(PREFS, 'user = {:user}', { user: userId })
} catch (err) {
record = null
}
if (!record) {
return DEFAULTS
}
const out = { enabled: record.getBool('enabled') }
for (const key in DEFAULTS) {
if (key !== 'enabled') {
out[key] = record.getBool(key)
}
}
return out
}
/** Will dieses Konto diese Art von Meldung? */
function wants(app, userId, type) {
const prefs = prefsOf(app, userId)
return !!prefs.enabled && !!prefs[type]
}
/**
* Die IDs einer Relation als gewöhnliches Array wie im Verzeichnis-Modul.
* `get()` gäbe je nach Feld einen String oder eine Go-Slice zurück.
*/
function idsOf(record, field) {
const raw = record.getStringSlice(field) || []
const out = []
for (let i = 0; i < raw.length; i++) {
if (raw[i]) {
out.push(raw[i])
}
}
return out
}
/** Alle Konten eines Teams: Mitglieder und der Owner. */
function teamMembers(app, teamId) {
let team
try {
team = app.findRecordById('teams', teamId)
} catch (err) {
return []
}
const ids = idsOf(team, 'users')
const owner = idsOf(team, 'owner')[0]
if (owner && ids.indexOf(owner) === -1) {
ids.push(owner)
}
return ids
}
/**
* Einen Eintrag in die Warteschlange legen für ein Konto.
*
* Wirft nie. Ein Auslöser hängt immer an einem Speichervorgang, den jemand
* gerade ausgelöst hat: Ein neues Event darf nicht daran scheitern, dass ein
* Hinweis nicht geschrieben werden konnte.
*/
function queueFor(app, userId, entry) {
if (!userId || !entry || !entry.type) {
return
}
// Der Auslöser selbst bekommt keine Meldung über sein eigenes Tun. Wer
// ein Event anlegt, weiß, dass es eins gibt.
if (entry.actor && entry.actor === userId) {
return
}
try {
if (!wants(app, userId, entry.type)) {
return
}
const record = new Record(app.findCollectionByNameOrId(QUEUE))
record.set('user', userId)
record.set('type', entry.type)
record.set('title', entry.title || '')
record.set('body', entry.body || '')
record.set('link', entry.link || '')
if (entry.team) {
record.set('team', entry.team)
}
app.save(record)
} catch (err) {
console.log('[notifications] konnte nicht einreihen:', err)
}
}
/** Denselben Eintrag für ein ganzes Team einreihen. */
function queueForTeam(app, teamId, entry) {
const members = teamMembers(app, teamId)
const withTeam = Object.assign({}, entry, { team: teamId })
for (let i = 0; i < members.length; i++) {
queueFor(app, members[i], withTeam)
}
}
/**
* Das Konto hinter einem Fahrer oder leer, wenn er keins hat.
* Ohne Konto gibt es keine Adresse und damit nichts zu benachrichtigen.
*/
function userOfRider(app, riderId) {
try {
return app.findRecordById('riders', riderId).getString('user')
} catch (err) {
return ''
}
}
/** Anzeigename eines Kontos für Sätze wie „X hat kommentiert". */
function nameOf(app, userId) {
if (!userId) {
return 'Jemand'
}
try {
const u = app.findRecordById('users', userId)
return u.getString('name') || u.getString('email') || 'Jemand'
} catch (err) {
return 'Jemand'
}
}
/**
* Einträge als versendet abhaken. Einzeln, damit ein hängender Datensatz
* nicht die ganze Gruppe mitreißt und morgen alles doppelt rausgeht.
*/
function markSent(app, items) {
const now = new Date().toISOString().replace('T', ' ')
for (let i = 0; i < items.length; i++) {
try {
items[i].set('sent', now)
app.save(items[i])
} catch (err) {
console.log('[notifications] konnte nicht abhaken:', err)
}
}
}
module.exports = {
PREFS: PREFS,
QUEUE: QUEUE,
DEFAULTS: DEFAULTS,
LABELS: LABELS,
prefsOf: prefsOf,
wants: wants,
idsOf: idsOf,
teamMembers: teamMembers,
queueFor: queueFor,
queueForTeam: queueForTeam,
userOfRider: userOfRider,
nameOf: nameOf,
markSent: markSent,
}

View file

@ -38,7 +38,10 @@ routerAdd('GET', '/api/team-invites/{token}', (e) => {
throw new NotFoundError('Diese Einladung gibt es nicht mehr.')
}
return e.json(200, { team: lib.teamPayload(e.app, invite) })
return e.json(200, {
team: lib.teamPayload(e.app, invite),
recipient: lib.recipientPayload(e.app, invite),
})
})
// --- Konto anlegen und beitreten -------------------------------------------
@ -80,6 +83,17 @@ routerAdd('POST', '/api/team-invites/{token}/accept', (e) => {
throw new BadRequestError('Diese Einladung gilt nicht mehr.')
}
// Eine persönliche Einladung ging an genau eine Adresse. Sie hier
// erneut zu prüfen ist der Punkt, an dem es zählt: Das Feld auf der
// Seite ist gesperrt, aber ein gesperrtes Feld ist eine Bitte und
// keine Bedingung — die Anfrage kann jeder von Hand stellen.
const invitedEmail = invite.getString('email')
if (invitedEmail && invitedEmail.toLowerCase() !== email.toLowerCase()) {
throw new BadRequestError(
'Diese Einladung gilt für eine andere E-Mail-Adresse.',
)
}
// Ohne die Vorabprüfung meldete PocketBase nur „Value must be unique".
// Der Hinweis auf den zweiten Weg ist hier mehr wert als die
// Fehlermeldung.
@ -143,3 +157,172 @@ routerAdd('POST', '/api/team-invites/{token}/join', (e) => {
return e.json(200, { ok: true, team: result.teamId })
}, $apis.requireAuth())
// --- Persönliche Einladung: Konto per Mail statt Passwort per Zuruf --------
//
// Der Ersatz für „Konto anlegen" im Kader. Dort tippte die Teamleitung eine
// Adresse und ein Passwort ein und musste dieses Passwort anschließend
// irgendwie zum Fahrer bringen — über einen Kanal, den niemand geplant hat.
// Sie kannte es außerdem, und der Fahrer hatte eins, das er sich nicht
// ausgesucht hat.
//
// Jetzt geht eine Mail raus, und das Passwort entsteht erst beim Empfänger.
//
// Warum eine Route und kein Collection-Create mit anschließendem Versand?
// Weil beides zusammengehört: Eine Einladung, die niemanden erreicht, ist
// keine — sie stünde nur als Datensatz herum und die Teamleitung wartete auf
// jemanden, der nie eine Mail bekommen hat. Hier scheitert beides zusammen
// oder gar nichts.
//
// POST /api/team-invites/send { rider, email, days? }
routerAdd('POST', '/api/team-invites/send', (e) => {
const lib = require(`${__hooks}/team_invites_lib.js`)
const mail = require(`${__hooks}/mail_lib.js`)
const user = e.auth
if (!user) {
throw new UnauthorizedError()
}
const base = mail.appURL()
if (!base) {
// Ohne Adresse des Frontends gibt es keinen Link, und ohne Link ist
// die Mail wertlos. Das hier ist ein Konfigurationsfehler und gehört
// sichtbar gemeldet, nicht stillschweigend umschifft.
throw new InternalServerError(
'APP_URL ist nicht gesetzt — ohne sie lässt sich kein Einladungslink bilden.',
)
}
const data = new DynamicModel({ rider: '', email: '', days: 14 })
e.bindBody(data)
const email = (data.email || '').trim()
if (email.indexOf('@') < 1) {
throw new BadRequestError('Bitte eine gültige E-Mail-Adresse angeben.')
}
let rider
try {
rider = e.app.findRecordById('riders', (data.rider || '').trim())
} catch (err) {
throw new NotFoundError('Diesen Fahrer gibt es nicht.')
}
if (rider.getString('user')) {
throw new BadRequestError('Dieser Fahrer hat schon ein Konto.')
}
const team = e.app.findRecordById('teams', rider.getString('team'))
// Einladen darf die Teamleitung — dieselbe Bedingung wie in der
// createRule von team_invites, hier von Hand geprüft, weil die Route an
// den Collection-Regeln vorbeiläuft.
let allowed = false
try {
e.app.findFirstRecordByFilter(
'teams',
'id = {:team} && (owner.id ?= {:user} || admins.id ?= {:user})',
{ team: team.id, user: user.id },
)
allowed = true
} catch (err) {
allowed = false
}
if (!allowed) {
throw new ForbiddenError('Einladen darf nur die Teamleitung.')
}
// Ohne die Vorabprüfung liefe der Eingeladene erst beim Anlegen des
// Kontos in „Value must be unique" — nach der Mail, nach dem Klick, am
// Ende eines Weges, den er umsonst gegangen wäre.
let existing = null
try {
existing = e.app.findAuthRecordByEmail('users', email)
} catch (err) {
existing = null
}
if (existing) {
throw new BadRequestError(
'Zu dieser Adresse gibt es schon ein Konto. Es kann über einen ' +
'Einladungslink ins Team kommen.',
)
}
const days = parseInt(data.days, 10)
let expires = ''
if (days > 0) {
const until = new Date(Date.now() + days * 24 * 60 * 60 * 1000)
expires = until.toISOString().replace('T', ' ').replace('Z', 'Z')
}
let invite
e.app.runInTransaction((txApp) => {
// Eine zweite Einladung an denselben Fahrer ersetzt die erste. Sonst
// stapelten sich bei jedem „nochmal senden" Datensätze, von denen
// jeder für sich gültig bliebe — und ein zurückgezogener wäre keiner.
try {
const old = txApp.findFirstRecordByFilter(
'team_invites',
'rider = {:rider}',
{ rider: rider.id },
)
txApp.delete(old)
} catch (err) {
// Keine vorhandene — der Normalfall.
}
invite = new Record(txApp.findCollectionByNameOrId('team_invites'))
invite.set('team', team.id)
invite.set('rider', rider.id)
invite.set('email', email)
invite.set('created_by', user.id)
invite.set('max_uses', 1)
invite.set('uses', 0)
if (expires) {
invite.set('expires', expires)
}
txApp.save(invite)
})
const name = rider.getString('name') || 'Radlfahrer'
const teamName = team.getString('name') || mail.appName()
// Der Versand steht bewusst NACH der Transaktion: Ein SMTP-Aufruf gehört
// nicht in eine offene Datenbanktransaktion, er dauert und kann hängen.
// Scheitert er, verschwindet die Einladung wieder — eine, von der niemand
// weiß, wäre schlimmer als keine.
try {
mail.sendView(
e.app,
{ address: email, name: name },
`${teamName} lädt dich zu ${mail.appName()} ein`,
'invite.html',
{
App: mail.appName(),
Team: teamName,
Name: name,
Link: `${base}/invite/${invite.getString('token')}`,
Expires: mail.formatDay(expires),
},
)
} catch (err) {
try {
e.app.delete(invite)
} catch (cleanupErr) {
// Aufräumen ist der Versuch wert, aber nicht der Fehler, den der
// Aufrufer sehen soll.
}
throw new InternalServerError(
'Die Einladung konnte nicht verschickt werden. Stimmen die SMTP-Einstellungen?',
)
}
invite.set('sent', new Date().toISOString().replace('T', ' '))
e.app.save(invite)
return e.json(200, { ok: true, email: email, expires: expires })
}, $apis.requireAuth())

View file

@ -56,6 +56,35 @@ function teamPayload(app, invite) {
}
}
/**
* Wem gilt die Einladung? Beim Einladungslink: niemandem Bestimmten.
*
* Eine persönliche Einladung nennt Adresse und Namen. Die Seite füllt beides
* vor und lässt die Adresse nicht ändern sonst legte jemand mit fremdem
* Link ein Konto auf eine Adresse an, an die nie eine Mail ging.
*
* Das ist keine Preisgabe: Wer den Token hat, hat die Mail bekommen, in der
* die Adresse ohnehin steht.
*/
function recipientPayload(app, invite) {
const email = invite.getString('email')
if (!email) {
return { email: '', name: '', personal: false }
}
let name = ''
const riderId = invite.getString('rider')
if (riderId) {
try {
name = app.findRecordById('riders', riderId).getString('name')
} catch (err) {
name = ''
}
}
return { email: email, name: name, personal: true }
}
/**
* Trägt einen bestehenden Nutzer ins Team ein und legt ihm einen Fahrer an,
* falls er dort noch keinen hat. Zählt die Einladung hoch.
@ -100,6 +129,29 @@ function joinTeam(txApp, invite, user, name) {
rider = null
}
// Eine persönliche Einladung nennt den Fahrer, um den es geht: Sie ging
// an jemanden, der längst im Kader steht und nur noch kein Konto hat.
// Ohne diesen Zweig entstünde daneben ein zweiter Fahrer gleichen Namens
// — dieselbe Person doppelt, einmal mit Zeiten und einmal mit Login.
if (!rider) {
const invitedRiderId = invite.getString('rider')
if (invitedRiderId) {
try {
const invited = txApp.findRecordById('riders', invitedRiderId)
// Nur, wenn er noch frei ist. Hat er inzwischen ein Konto,
// gehört er jemand anderem; dann ist ein eigener Fahrer für
// den Eingeladenen richtig.
if (invited.getString('team') === team.id && !invited.getString('user')) {
invited.set('user', user.id)
txApp.save(invited)
rider = invited
}
} catch (err) {
rider = null
}
}
}
if (!rider) {
rider = new Record(txApp.findCollectionByNameOrId('riders'))
rider.set('team', team.id)
@ -119,4 +171,4 @@ function joinTeam(txApp, invite, user, name) {
return { teamId: team.id, riderId: rider.id, joined: !isMember }
}
module.exports = { findValidInvite, teamPayload, joinTeam }
module.exports = { findValidInvite, teamPayload, recipientPayload, joinTeam }

View file

@ -0,0 +1,94 @@
<!doctype html>
<!--
Die Tageszusammenfassung — eine Mail statt zwanzig.
Gerendert mit $template.loadFiles(...).render(data), also Go's
html/template. {{ }} wird dabei escaped; Kommentartexte und Trailnamen
kommen aus der Datenbank und dürfen hier kein Markup einschleusen.
Tabellen und Inline-Styles aus demselben Grund wie in invite.html:
Mailprogramme lesen kein CSS aus dem <head> und Outlook rendert mit Word.
Erwartete Daten:
.App Name der Anwendung
.Name Anrede
.Count Wie viele Einträge insgesamt
.Groups [{ Label, Items: [{ Title, Body, Link }] }]
.SettingsLink Direkt zu den Benachrichtigungs-Einstellungen
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.App}} — Zusammenfassung</title>
</head>
<body style="margin:0; padding:0; background:#f4f4f5; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<div style="display:none; max-height:0; overflow:hidden; opacity:0;">
{{.Count}} Neuigkeiten aus deinen Teams.
</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5; padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="max-width:560px; background:#ffffff; border-radius:12px; overflow:hidden;">
<tr>
<td style="padding:32px 32px 0 32px;">
<p style="margin:0 0 4px 0; font-size:13px; letter-spacing:0.08em; text-transform:uppercase; color:#a1a1aa;">
{{.App}}
</p>
<h1 style="margin:0 0 8px 0; font-size:22px; line-height:1.3; color:#18181b;">
Was seit gestern passiert ist
</h1>
<p style="margin:0; font-size:15px; color:#71717a;">
Hallo {{.Name}} — {{.Count}} Neuigkeit{{if ne .Count 1}}en{{end}}.
</p>
</td>
</tr>
{{range .Groups}}
<tr>
<td style="padding:28px 32px 0 32px;">
<p style="margin:0 0 12px 0; font-size:12px; font-weight:700; letter-spacing:0.08em; text-transform:uppercase; color:#a1a1aa;">
{{.Label}}
</p>
{{range .Items}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="margin:0 0 10px 0; border-left:3px solid #eab308; background:#fafafa; border-radius:0 8px 8px 0;">
<tr>
<td style="padding:12px 14px;">
<p style="margin:0; font-size:15px; font-weight:600; color:#18181b;">
{{if .Link}}<a href="{{.Link}}" style="color:#18181b; text-decoration:none;">{{.Title}}</a>{{else}}{{.Title}}{{end}}
</p>
{{if .Body}}
<p style="margin:4px 0 0 0; font-size:14px; line-height:1.5; color:#71717a;">
{{.Body}}
</p>
{{end}}
</td>
</tr>
</table>
{{end}}
</td>
</tr>
{{end}}
<tr>
<td style="padding:28px 32px 32px 32px;">
<hr style="border:0; border-top:1px solid #e4e4e7; margin:0 0 16px 0;">
<p style="margin:0; font-size:13px; line-height:1.6; color:#a1a1aa;">
Zu viel oder zu wenig? Unter
<a href="{{.SettingsLink}}" style="color:#71717a;">Einstellungen → Benachrichtigungen</a>
stellst du ein, worüber diese Mail berichtet — oder schaltest sie ganz ab.
</p>
</td>
</tr>
</table>
<p style="margin:16px 0 0 0; font-size:12px; color:#a1a1aa;">{{.App}}</p>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,116 @@
<!doctype html>
<!--
Einladungsmail für ein neues Konto.
Gerendert wird sie mit $template.loadFiles(...).render(data) — Go's
html/template. Die Platzhalter stehen deshalb in {{ }} und werden dabei
escaped; {{.Team}} kommt aus einer Datenbank und darf hier kein Markup
einschleusen.
Warum Tabellen und Inline-Styles? Weil Mailprogramme kein CSS aus dem
<head> lesen, kein Flexbox können und Outlook bis heute mit Word rendert.
Was hier steht, ist die Form, die überall ankommt — nicht die, die man
schreiben würde.
Erwartete Daten:
.App Name der Anwendung
.Team Team, das einlädt
.Name Name des Eingeladenen, wie er im Kader steht
.Link Vollständige URL auf /invite/{token}
.Expires Klartext wie "30. September 2026" — leer heißt: läuft nicht ab
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Einladung zu {{.Team}}</title>
</head>
<body style="margin:0; padding:0; background:#f4f4f5; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<!-- Vorschautext: steht in der Liste neben dem Betreff und sonst nirgends. -->
<div style="display:none; max-height:0; overflow:hidden; opacity:0;">
{{.Team}} lädt dich zu {{.App}} ein. Passwort setzt du selbst.
</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5; padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="max-width:520px; background:#ffffff; border-radius:12px; overflow:hidden;">
<tr>
<td style="padding:32px 32px 8px 32px;">
<p style="margin:0 0 4px 0; font-size:13px; letter-spacing:0.08em; text-transform:uppercase; color:#a1a1aa;">
{{.App}}
</p>
<h1 style="margin:0; font-size:24px; line-height:1.3; color:#18181b;">
{{.Team}} lädt dich ein
</h1>
</td>
</tr>
<tr>
<td style="padding:16px 32px 0 32px; font-size:16px; line-height:1.6; color:#3f3f46;">
<p style="margin:0 0 16px 0;">Hallo {{.Name}},</p>
<p style="margin:0 0 16px 0;">
du stehst im Kader von <strong>{{.Team}}</strong>. Mit einem eigenen
Konto siehst du Termine, trägst dich zu Events ein und findest deine
Zeiten wieder.
</p>
<p style="margin:0 0 24px 0;">
Über den Knopf legst du das Konto selbst an — <strong>dein Passwort
wählst du dabei selbst</strong>, niemand sonst kennt es.
</p>
</td>
</tr>
<tr>
<td style="padding:0 32px;">
<!-- Der Knopf als Tabelle: Ein <a> mit Padding bricht in Outlook. -->
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:8px; background:#eab308;">
<a href="{{.Link}}"
style="display:inline-block; padding:14px 28px; font-size:16px; font-weight:600; color:#18181b; text-decoration:none;">
Konto anlegen
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:24px 32px 0 32px; font-size:14px; line-height:1.6; color:#71717a;">
<p style="margin:0 0 8px 0;">
Falls der Knopf nicht geht, diese Adresse in den Browser kopieren:
</p>
<p style="margin:0; word-break:break-all;">
<a href="{{.Link}}" style="color:#3f3f46;">{{.Link}}</a>
</p>
</td>
</tr>
{{if .Expires}}
<tr>
<td style="padding:16px 32px 0 32px; font-size:14px; line-height:1.6; color:#71717a;">
Die Einladung gilt bis zum {{.Expires}}.
</td>
</tr>
{{end}}
<tr>
<td style="padding:24px 32px 32px 32px;">
<hr style="border:0; border-top:1px solid #e4e4e7; margin:0 0 16px 0;">
<p style="margin:0; font-size:13px; line-height:1.6; color:#a1a1aa;">
Du erwartest keine Einladung? Dann ignorier diese Mail einfach —
ohne den Link entsteht kein Konto.
</p>
</td>
</tr>
</table>
<p style="margin:16px 0 0 0; font-size:12px; color:#a1a1aa;">{{.App}}</p>
</td>
</tr>
</table>
</body>
</html>

View file

@ -2144,6 +2144,47 @@ migrate((app) => {
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "email_invite_email",
"max": 0,
"min": 0,
"name": "email",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "spgs7e4anufen9u",
"help": "",
"hidden": false,
"id": "relation_invite_rider",
"maxSelect": 1,
"minSelect": 0,
"name": "rider",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"help": "",
"hidden": false,
"id": "date_invite_sent",
"max": "",
"min": "",
"name": "sent",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"help": "",
"hidden": false,
@ -2208,6 +2249,307 @@ migrate((app) => {
],
"system": false
}
,
{
"id": "pbc_notification_prefs",
"listRule": "@request.auth.id != \"\" && user = @request.auth.id",
"viewRule": "@request.auth.id != \"\" && user = @request.auth.id",
"createRule": "@request.auth.id != \"\" && @request.body.user = @request.auth.id",
"updateRule": "@request.auth.id != \"\" && user = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user = @request.auth.id",
"name": "notification_prefs",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"help": "",
"hidden": false,
"id": "relation_prefs_user",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"help": "",
"hidden": false,
"id": "bool_prefs_enabled",
"name": "enabled",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"help": "",
"hidden": false,
"id": "bool_prefs_event_created",
"name": "event_created",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"help": "",
"hidden": false,
"id": "bool_prefs_event_participation",
"name": "event_participation",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"help": "",
"hidden": false,
"id": "bool_prefs_event_changed",
"name": "event_changed",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"help": "",
"hidden": false,
"id": "bool_prefs_trail_comment",
"name": "trail_comment",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"help": "",
"hidden": false,
"id": "bool_prefs_trail_status",
"name": "trail_status",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"help": "",
"hidden": false,
"id": "bool_prefs_trail_marker",
"name": "trail_marker",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"help": "",
"hidden": false,
"id": "bool_prefs_team_member",
"name": "team_member",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"help": "",
"hidden": false,
"id": "autodate_created",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"help": "",
"hidden": false,
"id": "autodate_updated",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE UNIQUE INDEX `idx_notification_prefs_user` ON `notification_prefs` (`user`)"
],
"system": false
},
{
"id": "pbc_notifications",
"listRule": "@request.auth.id != \"\" && user = @request.auth.id",
"viewRule": "@request.auth.id != \"\" && user = @request.auth.id",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "notifications",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"help": "",
"hidden": false,
"id": "relation_notification_user",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"cascadeDelete": true,
"collectionId": "pbc_1568971955",
"help": "",
"hidden": false,
"id": "relation_notification_team",
"maxSelect": 1,
"minSelect": 0,
"name": "team",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_notification_type",
"max": 0,
"min": 0,
"name": "type",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_notification_title",
"max": 0,
"min": 0,
"name": "title",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_notification_body",
"max": 0,
"min": 0,
"name": "body",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_notification_link",
"max": 0,
"min": 0,
"name": "link",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "date_notification_sent",
"max": "",
"min": "",
"name": "sent",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"help": "",
"hidden": false,
"id": "autodate_created",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"help": "",
"hidden": false,
"id": "autodate_updated",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE INDEX `idx_notifications_pending` ON `notifications` (`user`, `sent`)"
],
"system": false
}
]
// importCollections erwartet das Array selbst, keinen JSON-String — die

View file

@ -0,0 +1,38 @@
/// <reference path="../pb_data/types.d.ts" />
// Konten löschen darf der Superadmin.
//
// Bis hierher galt `users.deleteRule = "id = @request.auth.id"`: Ein Konto
// konnte allein sein Inhaber löschen. Das ist die richtige Voreinstellung —
// eine Teamleitung hat an fremden Konten nichts verloren, sie verwaltet
// Fahrer und Mitgliedschaften, nicht Menschen. Genau dabei bleibt es; im
// Kader gibt es seitdem nur noch „Zugang zum Team entziehen".
//
// Was fehlte, war die Stelle darüber: Ein Konto, das niemand mehr braucht und
// dessen Inhaber sich nicht selbst darum kümmert, blieb für immer stehen. Das
// erledigt jetzt der Superadmin im Benutzer-Verzeichnis unter Einstellungen.
//
// Der Vorspann `@request.auth.id != ""` ist Pflicht (siehe
// 1754501700_rules_require_login.js): Ohne ihn wäre `@request.auth.superadmin`
// einer anonymen Anfrage ein leerer Wert — und leer gegen leer verglichen ist
// in PocketBase wahr.
//
// Gelöscht wird nur das Konto. Fahrer, Teilnahmen und Zeiten hängen am Team
// und bleiben stehen; sie sind dessen Geschichte und nicht die des Logins.
// Ein Team, dessen Owner das Konto ist, blockiert die Löschung — `teams.owner`
// ist eine Pflichtrelation ohne cascadeDelete. Die Oberfläche prüft das vorher
// und sagt, welches Team im Weg steht.
const RULE = 'id = @request.auth.id || (@request.auth.id != "" && @request.auth.superadmin = true)'
migrate((app) => {
const users = app.findCollectionByNameOrId('users')
users.deleteRule = RULE
app.save(users)
}, (app) => {
const users = app.findCollectionByNameOrId('users')
users.deleteRule = 'id = @request.auth.id'
app.save(users)
})

View file

@ -0,0 +1,83 @@
/// <reference path="../pb_data/types.d.ts" />
// Persönliche Einladungen — der zweite Weg zu einem Konto.
//
// Bis hierher gab es zwei Wege ins Team, und einer davon war schief:
//
// 1. Der Einladungslink. Er gilt für jeden, der ihn hat; wer ihn einlöst,
// legt sich selbst ein Konto an und vergibt sein eigenes Passwort.
// Dieser Weg bleibt unverändert.
//
// 2. „Konto anlegen" im Kader. Dort tippte die Teamleitung eine Adresse
// und ein Passwort ein — und musste dieses Passwort anschließend
// irgendwie zum Fahrer bringen. Über einen Kanal, den niemand geplant
// hat, im Zweifel per WhatsApp. Sie kannte es außerdem, und der Fahrer
// hatte ein Passwort, das er sich nicht ausgesucht hat.
//
// Der zweite Weg wird zur Einladungsmail: Die Teamleitung gibt nur noch die
// Adresse an, PocketBase verschickt einen Link, und der Fahrer setzt sein
// Passwort selbst. Niemand außer ihm kennt es je.
//
// Dafür bekommt `team_invites` drei Felder. Eine persönliche Einladung ist
// derselbe Datensatz wie ein Link, nur mit Adressat:
//
// email — an wen sie ging. Gesetzt heißt: Diese Einladung gilt nur für
// diese Adresse, die Anmeldeseite lässt keine andere zu.
// rider — welcher Fahrer sein Konto bekommt. Ohne dieses Feld legte der
// Beitritt einen zweiten Fahrer an und dieselbe Person stünde
// doppelt im Kader. cascadeDelete: Verschwindet der Fahrer,
// verfällt seine Einladung mit ihm.
// sent — wann die Mail rausging. Nur zur Anzeige („vor 3 Tagen
// eingeladen"), damit niemand raten muss, ob schon eine unterwegs
// ist.
//
// Alle drei sind optional. Ein Einladungslink lässt sie leer und verhält
// sich wie zuvor — die Felder erweitern den Datensatz, sie verändern ihn
// nicht.
migrate((app) => {
const invites = app.findCollectionByNameOrId('team_invites')
const riders = app.findCollectionByNameOrId('riders')
if (!invites.fields.getByName('email')) {
invites.fields.add(new Field({
id: 'email_invite_email',
name: 'email',
type: 'text',
}))
}
if (!invites.fields.getByName('rider')) {
invites.fields.add(new Field({
id: 'relation_invite_rider',
name: 'rider',
type: 'relation',
required: false,
maxSelect: 1,
collectionId: riders.id,
cascadeDelete: true,
}))
}
if (!invites.fields.getByName('sent')) {
invites.fields.add(new Field({
id: 'date_invite_sent',
name: 'sent',
type: 'date',
}))
}
// Die createRule bleibt, wie sie ist: Einladungen legt die Teamleitung
// an. Persönliche entstehen ohnehin nicht über die Collection, sondern
// über POST /api/team-invites/send — der Hook prüft dieselbe Bedingung
// und verschickt in einem Zug die Mail.
app.save(invites)
}, (app) => {
const invites = app.findCollectionByNameOrId('team_invites')
invites.fields.removeByName('email')
invites.fields.removeByName('rider')
invites.fields.removeByName('sent')
app.save(invites)
})

View file

@ -0,0 +1,158 @@
/// <reference path="../pb_data/types.d.ts" />
// Benachrichtigungen — eine Mail am Tag, und jeder bestimmt selbst, worüber.
//
// Zwei Collections, weil zwei verschiedene Dinge gebraucht werden:
//
// notification_prefs — was will jemand wissen? Ein Datensatz je Konto.
// notifications — was ist passiert? Die Warteschlange bis zum Versand.
//
// Warum eine Warteschlange und nicht sofort eine Mail? Weil sonst ein
// Trailkommentar, dem drei Antworten folgen, vier Mails auslöst — und ein
// Verein, der sich die nicht leisten kann, das Ganze am dritten Tag
// abschaltet. Der Cron-Job in pb_hooks/notifications.pb.js nimmt einmal
// täglich alles Unversendete, macht eine Mail daraus und hakt es ab.
//
// Die Warteschlange ist deshalb kein Zwischenspeicher, sondern der eigentliche
// Mechanismus: Der Auslöser schreibt hinein und ist fertig. Ob und wann etwas
// verschickt wird, entscheidet allein der Job. Ein Fehler beim Mailversand
// bringt damit auch nie das Speichern eines Events durcheinander.
//
// Wer nichts einstellt, hat trotzdem Voreinstellungen: `notification_prefs`
// entsteht erst beim ersten Speichern. Was ohne Datensatz gilt, steht als
// DEFAULTS in pb_hooks/notifications_lib.js — an einer Stelle, gültig für
// Auslöser und Oberfläche.
const OWN_RECORD = '@request.auth.id != "" && user = @request.auth.id'
migrate((app) => {
const users = app.findCollectionByNameOrId('users')
const teams = app.findCollectionByNameOrId('teams')
// --- Einstellungen ----------------------------------------------------
//
// Alles, was jemand über sich selbst festlegt. Die Regeln lassen nur den
// eigenen Datensatz zu — auch beim Anlegen, sonst schriebe jemand
// Einstellungen für ein fremdes Konto und bestimmte, was ein anderer
// erfährt.
try {
app.findCollectionByNameOrId('notification_prefs')
} catch {
const prefs = new Collection({
id: 'pbc_notification_prefs',
type: 'base',
name: 'notification_prefs',
listRule: OWN_RECORD,
viewRule: OWN_RECORD,
createRule: '@request.auth.id != "" && @request.body.user = @request.auth.id',
updateRule: OWN_RECORD,
deleteRule: OWN_RECORD,
fields: [
{
id: 'relation_prefs_user',
name: 'user',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
// Hauptschalter. Aus heißt: gar keine Mails, egal was die
// einzelnen Schalter sagen. Er steht vor allem für den Fall,
// dass jemand für zwei Wochen Ruhe haben will, ohne sich
// danach alles neu zusammenzuklicken.
{ id: 'bool_prefs_enabled', name: 'enabled', type: 'bool' },
{ id: 'bool_prefs_event_created', name: 'event_created', type: 'bool' },
{ id: 'bool_prefs_event_participation', name: 'event_participation', type: 'bool' },
{ id: 'bool_prefs_event_changed', name: 'event_changed', type: 'bool' },
{ id: 'bool_prefs_trail_comment', name: 'trail_comment', type: 'bool' },
{ id: 'bool_prefs_trail_status', name: 'trail_status', type: 'bool' },
{ id: 'bool_prefs_trail_marker', name: 'trail_marker', type: 'bool' },
{ id: 'bool_prefs_team_member', name: 'team_member', type: 'bool' },
{ id: 'autodate_created', name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
{ id: 'autodate_updated', name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
],
indexes: [
// Ein Datensatz je Konto. Zwei wären zwei Wahrheiten, und
// welche gilt, entschiede die Sortierung.
'CREATE UNIQUE INDEX `idx_notification_prefs_user` ON `notification_prefs` (`user`)',
],
})
app.save(prefs)
}
// --- Warteschlange ----------------------------------------------------
//
// Lesen darf jeder seine eigenen Einträge — daraus wird später einmal
// eine Glocke in der Oberfläche. Schreiben darf über die API niemand:
// Alle vier Regeln stehen auf null, was in PocketBase „nur Superuser"
// heißt. Gefüllt wird ausschließlich aus den Hooks, und die laufen an den
// Regeln vorbei. Sonst schriebe sich jeder selbst Meldungen — oder,
// schlimmer, anderen.
try {
app.findCollectionByNameOrId('notifications')
} catch {
const queue = new Collection({
id: 'pbc_notifications',
type: 'base',
name: 'notifications',
listRule: OWN_RECORD,
viewRule: OWN_RECORD,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
id: 'relation_notification_user',
name: 'user',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{
id: 'relation_notification_team',
name: 'team',
type: 'relation',
required: false,
maxSelect: 1,
collectionId: teams.id,
cascadeDelete: true,
},
// Welcher Schalter diesen Eintrag erlaubt hat. Steht mit
// drin, damit die Zusammenfassung nach Art gruppieren kann.
{ id: 'text_notification_type', name: 'type', type: 'text', required: true },
{ id: 'text_notification_title', name: 'title', type: 'text', required: true },
{ id: 'text_notification_body', name: 'body', type: 'text' },
// Relativer Pfad, kein vollständiger Link: Die Adresse der
// App steht in APP_URL und gehört nicht in jeden Datensatz.
{ id: 'text_notification_link', name: 'link', type: 'text' },
// Leer heißt: wartet auf die nächste Zusammenfassung.
{ id: 'date_notification_sent', name: 'sent', type: 'date' },
{ id: 'autodate_created', name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
{ id: 'autodate_updated', name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
],
indexes: [
// Der Job fragt genau danach: alles Unversendete, nach Konto.
'CREATE INDEX `idx_notifications_pending` ON `notifications` (`user`, `sent`)',
],
})
app.save(queue)
}
}, (app) => {
try {
app.delete(app.findCollectionByNameOrId('notifications'))
} catch {
// Schon fort.
}
try {
app.delete(app.findCollectionByNameOrId('notification_prefs'))
} catch {
// Schon fort.
}
})

49
design/logo/README.md Normal file
View file

@ -0,0 +1,49 @@
# Kurbeler — Logo
Der Name kommt vom Bauteil, das Zeichen auch: eine **Tretkurbel**. Kegeliger
Arm, dick am Tretlager, dünn am Pedal, an beiden Enden eine Bohrung. Aus dieser
einen Geometrie sind alle drei Varianten gebaut.
| Ordner | Variante | |
|---|---|---|
| `kurbel-k/` | **Kurbel-K** | Das K *ist* die Kurbel: zwei Arme aus dem Tretlager. Buchstabe und Bauteil in einer Form. **Im Einsatz.** |
| `kurbelarm/` | Kurbelarm | Ein einzelner Arm. Die ruhigste Lösung, braucht den Schriftzug daneben. |
| `garnitur/` | Kurbelgarnitur | Beide Arme in einer Linie, 180°, wie sie sich am Rad gegenüberstehen. |
## Dateien je Variante
| Datei | Wofür |
|---|---|
| `mark.svg` | Bildmarke allein, in `currentColor` |
| `icon.svg` | App-Symbol: gelbe Kachel, Marke in Tinte |
| `icon-dark.svg` | dasselbe umgekehrt, für dunkle Flächen |
| `icon-maskable.svg` | randlos für Android, Marke im sicheren Bereich (62 %) |
| `logo.svg` / `logo-dark.svg` | Marke und Schriftzug nebeneinander |
| `logo-mono.svg` | dasselbe einfarbig, alles `currentColor` |
| `icon-192.png`, `icon-512.png` | Raster für Betriebssysteme ohne SVG |
## Regeln
- **Zwei Farben, nie drei.** Kurbelgelb `#eab308` und Tinte `#18181b`, in der
einen oder der anderen Richtung. Kein Verlauf, kein Schatten.
- **Die Bohrungen sind Löcher.** Sie liegen als gegenläufig gewickelte Kreise
im selben Pfad, durch sie scheint der Untergrund. Nichts dahinterlegen, was
nicht durchscheinen soll.
- **Abstand zum Schriftzug**: ein Viertel der Markenhöhe. Darunter fällt die
Kombination auseinander.
- **Schriftzug**: Inter ExtraBold, Laufweite 0,023 em, in Kurven gewandelt
(`word.d`). Als Pfad, damit er ohne Schriftdatei überall gleich aussieht.
## Neu bauen
```bash
python3 design/logo/build.py
```
Braucht Inkscape (verschmilzt die Teilformen zu einem Umriss) und
`rsvg-convert` (PNG). Das Skript legt alle Varianten neu an und kopiert die
gesetzte — Kurbel-K — unter ihren Markennamen nach `frontend/static/`.
Die Pfaddaten für die App stehen zusätzlich in
`frontend/src/lib/brand.ts`; ändert sich die Form, muss die Datei mit den
Werten aus `geometry.json` nachgezogen werden.

186
design/logo/build.py Normal file
View file

@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""
Erzeugt saemtliche Logodateien aus einer einzigen Geometrie.
Die Form entsteht nicht von Hand, sondern aus Punkten und Radien: ein
Kurbelarm ist die Huellkurve zweier Kreise (dick am Tretlager, duenn am
Pedal), die Bohrungen sind gegenlaeufig gewickelte Kreise im selben Pfad.
Wer die Marke aendert, aendert die Zahlen hier oben und laesst das Skript
laufen dann stimmen App-Symbol, Schriftzug und PNG wieder zueinander.
python3 design/logo/build.py
Braucht Inkscape (Vereinigung der Teilformen) und rsvg-convert (PNG).
Der Schriftzug liegt als fertiger Pfad in `word.d`; er stammt aus Inter
ExtraBold, in Kurven gewandelt, und wird hier nur noch skaliert.
"""
import json, math, os, re, subprocess
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
STATIC = os.path.join(ROOT, "frontend", "static")
YELLOW = "#eab308" # yellow-500, die Themenfarbe der App
INK = "#18181b" # zinc-900
PAPER = "#fafaf9"
# Schriftzug: Inter ExtraBold, 128 px, Laufweite -3, in Pfade gewandelt.
WORD = open(os.path.join(HERE, "word.d")).read().strip()
WORD_BOX = dict(x=17.0455, y=46.9091, w=517.182, h=94.4091)
def hull(p1, r1, p2, r2):
"""Aussenkontur zweier Kreise — ein kegeliger Kurbelarm."""
(x1, y1), (x2, y2) = p1, p2
dx, dy = x2 - x1, y2 - y1
th = math.atan2(dy, dx)
be = math.acos(max(-1, min(1, (r1 - r2) / math.hypot(dx, dy))))
at = lambda c, r, a: (c[0] + r * math.cos(a), c[1] + r * math.sin(a))
A, B = at(p1, r1, th + be), at(p2, r2, th + be)
C, D = at(p2, r2, th - be), at(p1, r1, th - be)
return (f'M{A[0]:.3f} {A[1]:.3f}L{B[0]:.3f} {B[1]:.3f}'
f'A{r2} {r2} 0 0 0 {C[0]:.3f} {C[1]:.3f}'
f'L{D[0]:.3f} {D[1]:.3f}A{r1} {r1} 0 0 0 {A[0]:.3f} {A[1]:.3f}Z')
def bore(c, r):
"""Bohrung: gegenlaeufig gewickelt, damit sie im selben Pfad ein Loch ist."""
x, y = c
return (f'M{x - r:.3f} {y:.3f}A{r} {r} 0 1 1 {x + r:.3f} {y:.3f}'
f'A{r} {r} 0 1 1 {x - r:.3f} {y:.3f}Z')
def rrect(x, y, w, h, r):
return (f'M{x + r} {y}H{x + w - r}A{r} {r} 0 0 1 {x + w} {y + r}V{y + h - r}'
f'A{r} {r} 0 0 1 {x + w - r} {y + h}H{x + r}A{r} {r} 0 0 1 {x} {y + h - r}'
f'V{y + r}A{r} {r} 0 0 1 {x + r} {y}Z')
def union(name, parts):
"""Teilformen zu einem Umriss verschmelzen — sonst zeigen sich an den
Ueberlappungen Nahtkanten, und die Bohrungen wuerden dort zufaellig
wieder zuwachsen."""
body = "".join(f'<path id="p{i}" d="{d}" fill="#000"/>' for i, d in enumerate(parts))
src = os.path.join(HERE, f".{name}-parts.svg")
dst = os.path.join(HERE, f".{name}-union.svg")
open(src, "w").write(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" '
f'width="64" height="64">{body}</svg>')
subprocess.run(["inkscape", src, "--actions",
f"select-all;path-union;export-plain-svg;"
f"export-filename:{dst};export-do"], capture_output=True)
ds = re.findall(r'\sd="([^"]+)"', open(dst).read(), re.S)
assert len(ds) == 1, f"{name}: {len(ds)} Pfade statt einem"
os.remove(src); os.remove(dst)
return " ".join(ds[0].split())
# --- Die drei Varianten -----------------------------------------------------
# Tretlager J, Pedalaugen UP/LO. Die Arme sind am Lager dick (8.5) und am
# Pedal duenn (5.5) — so herum ist eine Kurbel gebaut.
J, UP, LO = (18.5, 32), (45, 13), (45, 51)
VARIANTS = {
"kurbel-k": dict(
name="Kurbel-K",
parts=[rrect(10, 10.5, 9.5, 43, 4.75), hull(J, 8.5, UP, 5.5), hull(J, 8.5, LO, 5.5)],
bores=[(J, 4.4), (UP, 2.7), (LO, 2.7)],
),
"kurbelarm": dict(
name="Kurbelarm",
parts=[hull((23, 14), 9.5, (41, 50), 6.2)],
bores=[((23, 14), 4.5), ((41, 50), 3.0)],
),
"garnitur": dict(
name="Kurbelgarnitur",
parts=[hull((32, 32), 10, (16.5, 47.5), 6.2), hull((32, 32), 10, (47.5, 16.5), 6.2)],
bores=[((32, 32), 4.8), ((16.5, 47.5), 3.0), ((47.5, 16.5), 3.0)],
),
}
def ink_box(path):
"""Tintenrahmen der fertigen Form — fuers optische Zentrieren."""
tmp = os.path.join(HERE, ".measure.svg")
open(tmp, "w").write(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" '
f'width="64" height="64"><path d="{path}" fill="#000"/></svg>')
out = subprocess.run(["inkscape", tmp, "--query-all"], capture_output=True, text=True).stdout
os.remove(tmp)
x, y, w, h = (float(v) for v in out.splitlines()[0].split(",")[1:5])
return x, y, x + w, y + h
def svg(w, h, body, view=None):
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{view or f"0 0 {w} {h}"}" '
f'width="{w}" height="{h}">{body}</svg>\n')
def build(key, spec, outdir):
path = union(key, spec["parts"]) + "".join(bore(c, r) for c, r in spec["bores"])
x0, y0, x1, y1 = ink_box(path)
mid = lambda s: (32 - (x0 + x1) / 2 * s, 32 - (y0 + y1) / 2 * s)
def centred(scale=1.0):
dx, dy = mid(scale)
return f'translate({dx:.4f} {dy:.4f}) scale({scale})'
def word_at(colour, cap, x):
s = cap / WORD_BOX["h"]
return (f'<g transform="translate({x - WORD_BOX["x"] * s:.3f} '
f'{32 - cap / 2 - WORD_BOX["y"] * s:.3f}) scale({s:.5f})">'
f'<path fill="{colour}" d="{WORD}"/></g>'), WORD_BOX["w"] * s
files = {}
files["mark.svg"] = svg(64, 64,
f'<g transform="{centred()}"><path fill="currentColor" d="{path}"/></g>')
# Die Kachel: Marke auf 84 %, damit sie nicht an die Rundung stoesst.
files["icon.svg"] = svg(64, 64,
f'<rect width="64" height="64" rx="14" fill="{YELLOW}"/>'
f'<g transform="{centred(0.84)}"><path fill="{INK}" d="{path}"/></g>')
files["icon-dark.svg"] = svg(64, 64,
f'<rect width="64" height="64" rx="14" fill="{INK}"/>'
f'<g transform="{centred(0.84)}"><path fill="{YELLOW}" d="{path}"/></g>')
# Randlos fuer Android: 62 %, alles ausserhalb kann beschnitten werden.
files["icon-maskable.svg"] = svg(64, 64,
f'<rect width="64" height="64" fill="{YELLOW}"/>'
f'<g transform="{centred(0.62)}"><path fill="{INK}" d="{path}"/></g>')
for fn, mc, tc in (("logo.svg", YELLOW, INK),
("logo-dark.svg", YELLOW, PAPER),
("logo-mono.svg", "currentColor", "currentColor")):
g, ww = word_at(tc, 38, x1 + 15)
files[fn] = svg(round(x1 + 15 + ww - x0, 2), 64,
f'<path fill="{mc}" d="{path}"/>{g}',
view=f'{x0:g} 0 {x1 + 15 + ww - x0:.2f} 64')
os.makedirs(outdir, exist_ok=True)
for fn, content in files.items():
open(os.path.join(outdir, fn), "w").write(content)
for px in (192, 512):
subprocess.run(["rsvg-convert", "-w", str(px), "-h", str(px),
os.path.join(outdir, "icon.svg"),
"-o", os.path.join(outdir, f"icon-{px}.png")], check=True)
return path, (x0, y0, x1, y1)
if __name__ == "__main__":
geometry = {}
for key, spec in VARIANTS.items():
path, box = build(key, spec, os.path.join(HERE, key))
geometry[key] = dict(path=path, box=box)
print(f"{key:12} {spec['name']}")
# Variante 01 ist die gesetzte: sie wandert unter ihrem Markennamen nach
# static/ und wird von Manifest, Favicon und den Komponenten benutzt.
live = "kurbel-k"
for src, dst in (("mark.svg", "kurbeler-mark.svg"),
("icon.svg", "kurbeler-icon.svg"),
("icon-maskable.svg", "kurbeler-icon-maskable.svg"),
("logo.svg", "kurbeler-logo.svg"),
("logo-dark.svg", "kurbeler-logo-dark.svg"),
("icon-192.png", "kurbeler-icon-192.png"),
("icon-512.png", "kurbeler-icon-512.png")):
s = os.path.join(HERE, live, src)
open(os.path.join(STATIC, dst), "wb").write(open(s, "rb").read())
print(f"\nnach frontend/static/ kopiert: {live}")
json.dump(geometry, open(os.path.join(HERE, "geometry.json"), "w"), indent=1)

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#18181b"/><g transform="translate(5.1205 5.1194) scale(0.84)"><path fill="#eab308" d="M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z"/></g></svg>

After

Width:  |  Height:  |  Size: 794 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" fill="#eab308"/><g transform="translate(12.1603 12.1595) scale(0.62)"><path fill="#18181b" d="M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z"/></g></svg>

After

Width:  |  Height:  |  Size: 788 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#eab308"/><g transform="translate(5.1205 5.1194) scale(0.84)"><path fill="#18181b" d="M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z"/></g></svg>

After

Width:  |  Height:  |  Size: 794 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10.2964 0 266.57 64" width="266.57" height="64"><path fill="#eab308" d="M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z"/><g transform="translate(61.842 -5.881) scale(0.40250)"><path fill="#fafaf9" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10.2964 0 266.57 64" width="266.57" height="64"><path fill="currentColor" d="M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z"/><g transform="translate(61.842 -5.881) scale(0.40250)"><path fill="currentColor" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10.2964 0 266.57 64" width="266.57" height="64"><path fill="#eab308" d="M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z"/><g transform="translate(61.842 -5.881) scale(0.40250)"><path fill="#18181b" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><g transform="translate(0.0006 -0.0008) scale(1.0)"><path fill="currentColor" d="M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z"/></g></svg>

After

Width:  |  Height:  |  Size: 746 B

29
design/logo/geometry.json Normal file
View file

@ -0,0 +1,29 @@
{
"kurbel-k": {
"path": "M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z",
"box": [
10.0,
7.5008,
50.4985,
56.4989
]
},
"kurbelarm": {
"path": "M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z",
"box": [
14.1962,
5.89055,
47.198899999999995,
56.197849999999995
]
},
"garnitur": {
"path": "M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z",
"box": [
10.2964,
10.2979,
53.7025,
53.7036
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#18181b"/><g transform="translate(6.5906 5.1201) scale(0.84)"><path fill="#eab308" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/></g></svg>

After

Width:  |  Height:  |  Size: 795 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" fill="#eab308"/><g transform="translate(13.2455 12.1601) scale(0.62)"><path fill="#18181b" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/></g></svg>

After

Width:  |  Height:  |  Size: 789 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#eab308"/><g transform="translate(6.5906 5.1201) scale(0.84)"><path fill="#18181b" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/></g></svg>

After

Width:  |  Height:  |  Size: 795 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 263.67 64" width="263.67" height="64"><path fill="#eab308" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/><g transform="translate(58.638 -5.881) scale(0.40250)"><path fill="#fafaf9" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 263.67 64" width="263.67" height="64"><path fill="currentColor" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/><g transform="translate(58.638 -5.881) scale(0.40250)"><path fill="currentColor" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 263.67 64" width="263.67" height="64"><path fill="#eab308" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/><g transform="translate(58.638 -5.881) scale(0.40250)"><path fill="#18181b" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><g transform="translate(1.7508 0.0002) scale(1.0)"><path fill="currentColor" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/></g></svg>

After

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#18181b"/><g transform="translate(6.2141 5.9229) scale(0.84)"><path fill="#eab308" d="M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z"/></g></svg>

After

Width:  |  Height:  |  Size: 538 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" fill="#eab308"/><g transform="translate(12.9675 12.7526) scale(0.62)"><path fill="#18181b" d="M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z"/></g></svg>

After

Width:  |  Height:  |  Size: 532 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#eab308"/><g transform="translate(6.2141 5.9229) scale(0.84)"><path fill="#18181b" d="M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z"/></g></svg>

After

Width:  |  Height:  |  Size: 538 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="14.1962 0 256.17 64" width="256.17" height="64"><path fill="#eab308" d="M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z"/><g transform="translate(55.338 -5.881) scale(0.40250)"><path fill="#fafaf9" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="14.1962 0 256.17 64" width="256.17" height="64"><path fill="currentColor" d="M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z"/><g transform="translate(55.338 -5.881) scale(0.40250)"><path fill="currentColor" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="14.1962 0 256.17 64" width="256.17" height="64"><path fill="#eab308" d="M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z"/><g transform="translate(55.338 -5.881) scale(0.40250)"><path fill="#18181b" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><g transform="translate(1.3025 0.9558) scale(1.0)"><path fill="currentColor" d="M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z"/></g></svg>

After

Width:  |  Height:  |  Size: 489 B

1
design/logo/word.d Normal file
View file

@ -0,0 +1 @@
M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z

View file

@ -1,10 +0,0 @@
# Was nicht ins Bild gehört.
.env
.env.*
!.env.example
node_modules
build
.svelte-kit
.git
.gitignore
*.log

View file

@ -1,27 +0,0 @@
# Der Wegweiser als Bild: mit Bun gebaut, von nginx ausgeliefert.
#
# Kein Node, kein Bun im Betrieb. Übrig sind drei vorgerenderte HTML-Seiten,
# ein Wappen und etwas CSS — dafür einen JavaScript-Prozess laufen zu lassen,
# der auf Anfragen wartet, um jedes Mal dieselbe Datei herauszugeben, wäre
# Beschäftigung ohne Arbeit. nginx tut genau das und sonst nichts.
ARG BUN_VERSION=1.3.11
# --- Bauen ------------------------------------------------------------------
FROM oven/bun:${BUN_VERSION}-alpine AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
# --- Ausliefern -------------------------------------------------------------
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/build /usr/share/nginx/html
EXPOSE 80

View file

@ -1,4 +1,4 @@
# stammtisch-hersbruck.de — Frontend
# Kurbeler — Frontend
SvelteKit-5-Anwendung (Svelte 5 Runes, Tailwind 4) für Zeitnahme und Verwaltung.

View file

@ -1,326 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "stammtisch-hersbruck.de",
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.18",
"@tailwindcss/vite": "^4.1.13",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.2",
"vite": "^7.1.7",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.1", "", { "os": "android", "cpu": "arm" }, "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.1", "", { "os": "android", "cpu": "arm64" }, "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.1", "", { "os": "none", "cpu": "arm64" }, "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="],
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
"@sveltejs/kit": ["@sveltejs/kit@2.70.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg=="],
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.11", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.20", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.9.2", "", {}, "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w=="],
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.3.7", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
"obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"svelte": ["svelte@5.57.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg=="],
"svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
}
}

View file

@ -1,17 +0,0 @@
services:
frontend:
build:
context: .
args:
BUN_VERSION: ${BUN_VERSION:-1.3.11}
restart: unless-stopped
# `expose`, nicht `ports`: Der Port gehört ins Docker-Netz. Coolify stellt
# die Seite über Traefik zu.
expose:
- "80"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 5s

View file

@ -1,40 +0,0 @@
# Ein Server, ein Verzeichnis, drei Seiten.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Vorkomprimierte Fassungen liegen dank `precompress` im Build daneben
# (.gz). Ohne diese Zeile ignoriert nginx sie und packt jede Antwort neu.
#
# Sie steht hier im Server-Block und nicht per `sed` in der
# `nginx.conf` des Bildes: Dort war die erste Zeile, die mit `}` beginnt,
# das Ende von `events { }`. `gzip_static` ist dort keine gültige
# Anweisung, nginx verweigerte den Start, und der Container ging sofort
# wieder aus „exited:unhealthy", ohne dass im Deploy-Log etwas
# danebenstand.
gzip_static on;
# Die Seiten sind beim Bauen entstanden und ändern sich nur mit dem
# nächsten Deployment. Das HTML selbst bleibt trotzdem ungecacht: Sonst
# zeigt ein Browser den Wegweiser von vorgestern, wenn sich der Text
# ändert.
location / {
try_files $uri $uri/ $uri.html =404;
add_header Cache-Control "no-cache";
}
# Alles unter /_app/immutable trägt seinen Hash im Namen das darf ein
# Jahr im Cache liegen.
location /_app/immutable/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /stammtisch-hersbruck.svg {
add_header Cache-Control "public, max-age=604800";
}
error_page 404 /index.html;
}

6361
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "stammtisch-hersbruck.de",
"name": "kurbeler",
"private": true,
"version": "2.0.0-alpha",
"version": "2.0.0-beta",
"type": "module",
"engines": {
"node": "^20.19 || ^22.12 || >=24"
@ -12,21 +12,43 @@
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"test:watch": "vitest",
"generate-pocketbase-types": "node scripts/schema-to-json.mjs > .schema.tmp.json && pocketbase-typegen --json .schema.tmp.json --out ./src/lib/types.d.ts && rm -f .schema.tmp.json"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@internationalized/date": "^3.12.1",
"@lucide/svelte": "^1.42.0",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.18",
"@tailwindcss/vite": "^4.1.13",
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"jsdom": "^29.1.1",
"pocketbase-typegen": "^1.3.1",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.2",
"vite": "^7.1.7"
"vite": "^7.1.7",
"vitest": "^4.1.10"
},
"dependencies": {}
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"date-fns": "^4.1.0",
"dompurify": "^3.4.13",
"lucide-svelte": "^0.503.0",
"maplibre-gl": "^6.2.0",
"mode-watcher": "^1.1.0",
"pocketbase": "^0.26.0",
"tippy.js": "^6.3.7"
}
}

View file

@ -0,0 +1,52 @@
/**
* Extrahiert das Collections-Array aus der PocketBase-Migration und schreibt es
* als JSON auf stdout in der Form, die `pocketbase-typegen --json` erwartet.
*
* Damit entstehen die TypeScript-Typen aus dem versionierten Schema im Repo,
* ohne laufende Instanz, ohne Token und ohne Netzwerkzugriff.
*
* Aufruf über npm run generate-pocketbase-types.
*/
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
const MIGRATIONS_DIR = new URL('../../backend/pb_migrations/', import.meta.url).pathname
// Die Snapshot-Migration ist die einzige, die ein vollständiges
// `const collections = [...]` enthält. Bei mehreren Dateien gewinnt die
// jüngste — Migrationen sind nach Zeitstempel benannt.
const candidates = readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith('.js'))
.sort()
.reverse()
const MARKER = 'const collections = '
for (const file of candidates) {
const source = readFileSync(join(MIGRATIONS_DIR, file), 'utf8')
const start = source.indexOf(MARKER)
if (start === -1) continue
// Vom öffnenden [ bis zur zugehörigen schließenden Klammer zählen —
// ein Regex käme an den verschachtelten Arrays der Feldoptionen nicht vorbei.
const from = start + MARKER.length
let depth = 0
for (let i = from; i < source.length; i++) {
if (source[i] === '[') depth++
else if (source[i] === ']') {
depth--
if (depth === 0) {
process.stdout.write(source.slice(from, i + 1))
process.exit(0)
}
}
}
}
console.error(
`Keine Migration mit "${MARKER.trim()}" in ${MIGRATIONS_DIR} gefunden.\n` +
`Ohne sie lassen sich die Typen nicht aus dem Repo erzeugen.`
)
process.exit(1)

View file

@ -1,59 +1,92 @@
@import "tailwindcss";
@import "tw-animate-css";
/*
* Schriften liegen im Bundle, nicht bei Google: Ein Aufruf an fonts.gstatic.com
* schickt die IP jedes Besuchers an einen Dritten, und dafuer bräuchte es eine
* Einwilligung, die niemand geben will, um eine Zeit zu stoppen.
*
* Inter als variable Schrift ein Datei je Subset statt einer je Schnitt,
* dafuer jedes Gewicht zwischen 100 und 900. IBM Plex Mono in genau zwei
* Schnitten, mehr braucht es fuer Zahlen und Kleinschriften nicht.
*/
@import "@fontsource-variable/inter/wght.css";
@import "@fontsource/ibm-plex-mono/latin-400.css";
@import "@fontsource/ibm-plex-mono/latin-600.css";
@custom-variant dark (&:is(.dark *));
/* shadcn theme: yellow (oklch) */
/*
* Die Farbwelt der Werkstatt: warme Neutrale statt des kuehlen Zinc-Grau,
* das shadcn mitbringt. Alle Grautoene stehen auf Farbton 92 mit sehr wenig
* Buntheit sie sind nicht grau, sondern *sehr blass gelb*, dieselbe Familie
* wie der Akzent. Nebeneinander wirkt das ruhig statt zusammengewuerfelt.
*
* Der Akzent bleibt unveraendert: #eab308 ist die Themenfarbe der App, die
* Farbe des Logos und die des Installationssymbols.
*
* Der Grund ist bewusst nicht Weiss. Karten sind es dadurch heben sie sich
* ohne Rahmen und ohne Schatten ab, und der Schatten steht wieder fuer das,
* wofuer er da ist: etwas, das ueber der Seite schwebt.
*/
:root {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--radius: 0.5rem;
--background: oklch(0.973 0.005 92);
--foreground: oklch(0.19 0.008 88);
--card: oklch(0.995 0.002 92);
--card-foreground: oklch(0.19 0.008 88);
--popover: oklch(0.995 0.002 92);
--popover-foreground: oklch(0.19 0.008 88);
--primary: oklch(0.795 0.184 86.047);
--primary-foreground: oklch(0.421 0.095 57.708);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.951 0.007 92);
--secondary-foreground: oklch(0.24 0.008 88);
--muted: oklch(0.951 0.007 92);
--muted-foreground: oklch(0.535 0.014 88);
--accent: oklch(0.951 0.007 92);
--accent-foreground: oklch(0.24 0.008 88);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--border: oklch(0.898 0.011 90);
--input: oklch(0.898 0.011 90);
--ring: oklch(0.795 0.184 86.047);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar: oklch(0.985 0.004 92);
--sidebar-foreground: oklch(0.19 0.008 88);
--sidebar-primary: oklch(0.795 0.184 86.047);
--sidebar-primary-foreground: oklch(0.421 0.095 57.708);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-accent: oklch(0.951 0.007 92);
--sidebar-accent-foreground: oklch(0.24 0.008 88);
--sidebar-border: oklch(0.898 0.011 90);
--sidebar-ring: oklch(0.795 0.184 86.047);
}
/*
* Dunkel ist hier nicht Schwarz, sondern angeraeuchertes Holz derselbe
* Farbton 90, nur unten in der Helligkeit. Reines Schwarz laesst das Gelb
* grell aussehen; auf dem warmen Grund sitzt es ruhig.
*
* Raender sind halbdurchsichtiges Weiss und keine eigene Farbe: So bleiben
* sie auf Karte und Grund gleich sichtbar, ohne dass es zwei Tokens braucht.
*/
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--background: oklch(0.158 0.006 90);
--foreground: oklch(0.968 0.005 92);
--card: oklch(0.215 0.007 90);
--card-foreground: oklch(0.968 0.005 92);
--popover: oklch(0.215 0.007 90);
--popover-foreground: oklch(0.968 0.005 92);
--primary: oklch(0.852 0.199 91.936);
--primary-foreground: oklch(0.421 0.095 57.708);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--secondary: oklch(0.272 0.008 90);
--secondary-foreground: oklch(0.968 0.005 92);
--muted: oklch(0.272 0.008 90);
--muted-foreground: oklch(0.702 0.014 90);
--accent: oklch(0.272 0.008 90);
--accent-foreground: oklch(0.968 0.005 92);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
@ -63,17 +96,33 @@
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar: oklch(0.215 0.007 90);
--sidebar-foreground: oklch(0.968 0.005 92);
--sidebar-primary: oklch(0.852 0.199 91.936);
--sidebar-primary-foreground: oklch(0.421 0.095 57.708);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.272 0.008 90);
--sidebar-accent-foreground: oklch(0.968 0.005 92);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.554 0.135 66.442);
}
@theme inline {
/*
* Zwei Schriften mit klarer Arbeitsteilung: Inter traegt alles, was man
* liest Ueberschriften, Saetze, Knoepfe. Plex Mono traegt alles, was
* man *abliest* Zeiten, Startnummern, Koordinaten, Kennzahlen. Der
* Wechsel ist damit nicht Geschmack, sondern eine Aussage darueber, was
* fuer eine Art Zahl da steht.
*/
--font-sans: 'Inter Variable', 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
/*
* Radien nach Rolle, nicht nach Geschmack: Bedienelemente sind klein und
* sitzen dicht, sie bekommen wenig (sm/md); Flaechen, auf denen etwas
* steht, bekommen mehr (lg/xl). Vorher lag ein einziger Wert von 0,65rem
* auf allem, vom Knopf bis zur Seite.
*/
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@ -117,6 +166,30 @@
}
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
/*
* Inters echtes Einzelstockwerk-a und -g (`cv11`) und der geschlitzte
* Null (`ss01`) an einer Strecke wird nebenbei gelesen, da hilft
* jeder Buchstabe, der nur einer Sache aehnlich sieht.
*/
font-feature-settings: 'cv11', 'ss01';
}
/*
* Ueberschriften stehen eng: Inter laeuft von Haus aus grosszuegig, was
* bei 24 px und mehr auseinanderfaellt. Der Wert sinkt mit steigender
* Groesse, so wie es eine Schrift mit optischen Groessen selbst taete.
*/
h1, h2, h3 {
letter-spacing: -0.018em;
}
/*
* Ziffern in Tabellen und Listen stehen untereinander dieselbe Breite
* fuer jede Ziffer, sonst wandert die Zehntelsekunde von Zeile zu Zeile.
*/
table, .tabular {
font-variant-numeric: tabular-nums;
}
/*
@ -148,15 +221,43 @@
@layer components {
/*
* Startnummern wie am Trikot: schmal, fett, ohne Zierrat. Impact ist auf
* Windows und macOS da, Haettenschweiler und Arial Narrow Bold springen
* ein; am Ende steht eine beliebige serifenlose Schrift, dann sieht die
* Nummer eben normal aus statt falsch.
* Startnummern. Vorher stand hier Impact mit vier Rueckfallschriften
* eine Wette darauf, was auf dem Geraet installiert ist, die auf Android
* und Linux verloren ging. Jetzt traegt sie Plex Mono, die ohnehin im
* Bundle liegt: jede Ziffer gleich breit, damit eine 11 nicht schmaler
* aussieht als eine 44, und weit gesperrt, damit sie auf Abstand gelesen
* wird statt als Zahl ueberflogen.
*/
.font-startnumber {
font-family: Impact, Haettenschweiler, 'Arial Narrow Bold', 'Franklin Gothic Bold', sans-serif;
font-weight: 700;
font-stretch: condensed;
letter-spacing: 0.02em;
font-family: var(--font-mono);
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.06em;
}
/*
* Die Kleinschrift des Hauses: Kapitaelchen-Ersatz fuer alles, was ein
* Feld benennt statt es zu fuellen Spaltenkoepfe, Abschnittsmarken,
* Beschriftungen ueber Kennzahlen. Gesperrt und klein, damit sie neben
* dem Wert zuruecktritt, aber Mono, damit sie als System erkennbar ist.
*/
.label-mono {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
line-height: 1.4;
color: var(--muted-foreground);
}
/*
* Zeiten und Messwerte. Mono und tabellarisch, damit Zahlen spaltenweise
* stehen; leicht zusammengezogen, weil Plex Mono sonst breit baut.
*/
.numeric {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
}

View file

@ -1,9 +1,11 @@
<!doctype html>
<html lang="de">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/stammtisch-hersbruck.svg" />
<!-- Installierbar, damit die Zeitnahme an der Strecke ein Funkloch
auch dann uebersteht, wenn jemand die App neu startet. -->
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#eab308" />
%sveltekit.head%
</head>

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

34
frontend/src/lib/brand.ts Normal file
View file

@ -0,0 +1,34 @@
/**
* Die Marke als Geometrie, an einer Stelle.
*
* Beide Pfade sind fertige Umrisse der Schriftzug ist in Kurven gewandelte
* Inter ExtraBold, keine Textzeile. Er sieht damit ueberall gleich aus, auch
* wo Inter nicht installiert ist, und braucht keine Schriftdatei.
*
* Die Bildmarke ist eine Tretkurbel in Form eines K: zwei kegelige Arme aus
* dem Tretlager, die Loecher sitzen an Lager und Pedalgewinde. Es ist *ein*
* Pfad mit gegenlaeufig gewickelten Kreisen die Bohrungen sind echte
* Loecher, durch die der Untergrund scheint, keine aufgesetzten Punkte.
* Deshalb darf nichts dahinterliegen, was nicht durchscheinen soll.
*
* Erzeugt aus `frontend/static/kurbeler-*.svg`; wer die Form aendert, aendert
* beides.
*/
/** Bildmarke, gezeichnet in einer 64x64-Flaeche. */
export const MARK = {
path: 'M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z',
/** Ruecken die Marke optisch in die Mitte der Flaeche. */
dx: 1.7508,
dy: 0.0002,
}
/** Marke und Schriftzug nebeneinander, Hoehe 64. */
export const LOCKUP = {
viewBox: '10 0 263.67 64',
word: 'M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z',
/** Schriftzug: verschieben, dann verkleinern. */
x: 58.638,
y: -5.881,
scale: 0.40250,
}

View file

@ -0,0 +1,91 @@
<script lang="ts">
/**
* Bild eines Nutzers — und wenn keins hinterlegt ist, ein erzeugtes aus
* Initialen und einer Farbe.
*
* Die Farbe kommt aus einer Prüfsumme über die ID: Derselbe Mensch bekommt
* damit überall dasselbe Bild, ohne dass es irgendwo gespeichert werden
* müsste. Bewusst kein externer Avatar-Dienst — der würde bei jedem
* Seitenaufruf verraten, wer hier gerade unterwegs ist.
*/
import { getFileURL } from '$lib/stores/pocketbase.svelte'
let {
user = null,
size = 32,
class: className = '',
}: {
/**
* Ein users-Record — oder das Wenige daraus, das dieses Bild braucht.
* `collectionId`/`collectionName` stehen mit drin, weil getFileURL
* eins von beiden verlangt: Ein schlanker Datensatz aus einem Hook
* bringt es nicht von selbst mit, ein echter Record schon.
*/
user?: {
id?: string
name?: string
email?: string
avatar?: string
collectionId?: string
collectionName?: string
} | null
size?: number
class?: string
} = $props()
const label = $derived(user?.name?.trim() || user?.email?.trim() || '')
/** Erste Buchstaben der ersten beiden Wörter, sonst der erste des Namens. */
const initials = $derived.by(() => {
if (!label) return '?'
const words = label.split(/[\s.@_-]+/).filter(Boolean)
if (words.length === 0) return '?'
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[1][0]).toUpperCase()
})
/**
* Farbton aus der ID, hilfsweise aus dem Namen. Sättigung und Helligkeit
* bleiben fest, damit weiße Schrift auf jedem Ton lesbar bleibt — auch im
* Dunkelmodus, wo eine zufällige Helligkeit schnell blendet.
*/
const hue = $derived.by(() => {
const seed = user?.id || label || '?'
let h = 0
for (let i = 0; i < seed.length; i++) {
h = (h * 31 + seed.charCodeAt(i)) % 360
}
return h
})
const src = $derived(
user?.avatar
? getFileURL(user as object, user.avatar, { thumb: `${size * 2}x${size * 2}` })
: null,
)
</script>
{#if src}
<img
{src}
alt={label}
width={size}
height={size}
class="shrink-0 rounded-full object-cover {className}"
style:width="{size}px"
style:height="{size}px"
/>
{:else}
<span
aria-hidden="true"
class="grid shrink-0 place-items-center rounded-full font-medium text-white select-none {className}"
style:width="{size}px"
style:height="{size}px"
style:background="hsl({hue} 55% 42%)"
style:font-size="{Math.round(size * 0.4)}px"
>
{initials}
</span>
{/if}

View file

@ -0,0 +1,120 @@
<script lang="ts">
/**
* Uhrenabgleich vor dem Event — der Dialog, den jeder aufruft, der an
* diesem Tag Zeiten nimmt.
*
* Warum das nötig ist: Zeiten entstehen auf den Geräten an der Strecke,
* damit im Funkloch überhaupt gestoppt werden kann. Startet aber einer am
* Start und stoppt ein anderer im Ziel, steckt der Versatz beider
* Telefonuhren in der gemessenen Dauer. Der Abgleich holt beide auf
* dieselbe Skala — die des Servers.
*
* Der Versatz gehört dem Gerät und liegt im localStorage; er überlebt
* Seitenwechsel und Abmeldung, aber nicht das Löschen der Websitedaten.
*/
import { clockSync, ACCURACY_LIMIT_MS, STALE_AFTER_MS } from '$lib/stores/clockSync.svelte'
import { Button } from '@/components/ui/button'
import * as Dialog from '@/components/ui/dialog'
import { CircleCheck, RefreshCw, TriangleAlert } from 'lucide-svelte'
let { open = $bindable(false) }: { open?: boolean } = $props()
/** Millisekunden lesbar: unter einer Sekunde in ms, darüber in Sekunden. */
function ms(value: number): string {
const abs = Math.abs(value)
if (abs < 1000) return `${Math.round(value)} ms`
return `${(value / 1000).toFixed(value < 10000 ? 2 : 1)} s`
}
function age(value: number): string {
const min = Math.round(value / 60000)
if (min < 1) return 'gerade eben'
if (min < 60) return `vor ${min} min`
const h = Math.round(min / 60)
return `vor ${h} h`
}
const good = $derived(clockSync.trustworthy)
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Uhr abgleichen</Dialog.Title>
<Dialog.Description>
Gestartet und gestoppt wird auf den Geräten an der Strecke. Damit die
Zeit stimmt, auch wenn Start und Ziel verschiedene Telefone sind,
stellt jedes Gerät seine Uhr einmal gegen den Server.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div
class="flex items-start gap-3 rounded-lg border p-3"
class:border-primary={good}
class:bg-primary={false}
>
{#if good}
<CircleCheck class="mt-0.5 size-5 shrink-0 text-primary" />
{:else}
<TriangleAlert class="mt-0.5 size-5 shrink-0 text-amber-600" />
{/if}
<div class="min-w-0 flex-1 space-y-1">
{#if !clockSync.measured}
<p class="text-sm font-medium">Noch nicht abgeglichen</p>
<p class="text-sm text-muted-foreground">
Bis dahin gilt die Uhr dieses Geräts, wie sie ist.
</p>
{:else}
<p class="text-sm font-medium">
Dieses Gerät geht
{#if Math.abs(clockSync.offset) < 50}
richtig
{:else if clockSync.offset < 0}
{ms(-clockSync.offset)} vor
{:else}
{ms(clockSync.offset)} nach
{/if}
</p>
<p class="text-sm text-muted-foreground">
Gemessen {age(clockSync.age ?? 0)}, auf ±{ms(clockSync.accuracy ?? 0)} genau.
Die Abweichung wird beim Stoppen herausgerechnet.
</p>
{#if clockSync.stale}
<p class="text-sm text-amber-600">
Die Messung ist älter als
{Math.round(STALE_AFTER_MS / 3600000)} Stunden — vor dem Start
noch einmal abgleichen.
</p>
{:else if (clockSync.accuracy ?? 0) > ACCURACY_LIMIT_MS}
<p class="text-sm text-amber-600">
Die Verbindung war zu langsam für eine genaue Messung. Mit
besserem Empfang noch einmal versuchen.
</p>
{/if}
{/if}
{#if clockSync.error}
<p class="text-sm text-destructive">{clockSync.error}</p>
{/if}
</div>
</div>
<p class="text-xs text-muted-foreground">
Der Abgleich braucht Netz und dauert eine Sekunde. Danach zählt die
Zeitnahme auch im Funkloch richtig weiter — übertragen wird, sobald
wieder Empfang da ist.
</p>
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)}>Schließen</Button>
<Button onclick={() => clockSync.measure()} disabled={clockSync.busy}>
<RefreshCw class="size-4 mr-2 {clockSync.busy ? 'animate-spin' : ''}" />
{clockSync.busy ? 'Wird gemessen …' : 'Jetzt abgleichen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { app } from '$lib/stores/app.svelte'
import { Button } from '@/components/ui/button'
import * as Dialog from '@/components/ui/dialog'
let open = $derived(app.confirm.show)
function onOpenChange(v: boolean) {
if (!v) {
// ESC oder Backdrop-Click → wie "no"
app.confirm.no()
}
}
</script>
<Dialog.Root bind:open={() => open, onOpenChange}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{app.confirm.title}</Dialog.Title>
{#if app.confirm.text}
<Dialog.Description>{app.confirm.text}</Dialog.Description>
{/if}
</Dialog.Header>
{#if app.confirm.error}
<div class="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{typeof app.confirm.error === 'object' ? (app.confirm.error as any).message ?? 'Fehler' : 'Fehler'}
</div>
{/if}
<Dialog.Footer>
<Button variant="ghost" onclick={() => app.confirm.no()}>Abbrechen</Button>
<Button variant="destructive" onclick={() => app.confirm.yes()}>Bestätigen</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -0,0 +1,76 @@
<script lang="ts">
/**
* Dialog „Neues Team". Liegt als eigene Komponente vor, weil es zwei
* Einstiege gibt — den Teamwechsler im Kopf und die Teamseite — und ein
* neues Team an beiden Stellen dasselbe bedeuten muss: Team anlegen, den
* Standardsatz Flag-Typen dazu, und ab dann ist es das aktive Team.
*
* Ein Team ohne Flag-Typen kann zwar Marker aufnehmen, aber nichts
* einordnen. Deshalb kommt der Standardsatz gleich mit — änderbar und,
* wie alle Flag-Typen, allein Sache dieses Teams.
*/
import { getTeamContext } from '$lib/stores/teams.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { Button } from '@/components/ui/button'
import * as Dialog from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
let { open = $bindable(false) }: { open?: boolean } = $props()
const teams = getTeamContext()
const flags = getTrailFlagContext()
let name = $state('')
let error = $state<string | null>(null)
let busy = $state(false)
async function submit() {
if (!name.trim() || busy) return
busy = true
error = null
try {
const created = await teams.create(name.trim())
try {
await flags.seedDefaults(created.id)
} catch (e: any) {
error =
'Das Team wurde angelegt, die Standard-Flag-Typen aber nicht: ' +
(e.message ?? 'unbekannter Fehler') +
' Du kannst sie unter „Flag-Typen" nachholen.'
return
}
open = false
name = ''
} catch (e: any) {
error = e.message ?? 'Das Team konnte nicht angelegt werden.'
} finally {
busy = false
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Neues Team</Dialog.Title>
<Dialog.Description>Du wirst automatisch Owner.</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="team-name">Name</Label>
<Input id="team-name" bind:value={name} placeholder="Mein Team" onkeydown={(e) => e.key === 'Enter' && submit()} />
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
<Button onclick={submit} disabled={!name.trim() || busy}>Erstellen</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -0,0 +1,217 @@
<script lang="ts">
/**
* Höhenprofil als SVG-Fläche. Die hervorgehobene Stelle kommt als Prop
* herein und geht beim Überfahren als Callback hinaus — die Elternseite
* hält den Wert und reicht ihn ebenso an die Karte weiter. So zeigen
* Karte und Profil immer dieselbe Stelle, egal wo gerade gezeigt wird.
* Gekoppelt wird über die Distanz, nicht über den Index: Die Karte zeigt
* eine vereinfachte Punktreihe, das Profil alle Originalpunkte.
*/
let {
elevation,
onhover,
onselect,
highlightDistance = null,
height = '120px',
}: {
elevation: { d: number; ele: number }[]
onhover?: (distance: number | null) => void
/**
* Klick auf eine Stelle des Profils. Damit lässt sich ein Marker auch
* über die Höhe finden — im steilen Stück, statt die Linie auf der
* Karte abzusuchen.
*/
onselect?: (distance: number) => void
highlightDistance?: number | null
height?: string
} = $props()
// Internes Koordinatensystem; das SVG skaliert über viewBox mit.
const W = 1000
const H = 200
const stats = $derived.by(() => {
if (elevation.length < 2) return null
const eles = elevation.map((p) => p.ele)
const min = Math.min(...eles)
const max = Math.max(...eles)
const totalD = elevation[elevation.length - 1].d || 1
// Bei flachen Strecken wäre die Spanne 0 und die Division ungültig.
const span = max - min || 1
return { min, max, totalD, span }
})
const path = $derived.by(() => {
if (!stats) return ''
const { min, totalD, span } = stats
const pts = elevation.map((p) => {
const x = (p.d / totalD) * W
const y = H - ((p.ele - min) / span) * H
return `${x.toFixed(1)},${y.toFixed(1)}`
})
// Fläche schließen: runter zur Grundlinie und zurück zum Anfang
return `M ${pts.join(' L ')} L ${W},${H} L 0,${H} Z`
})
/**
* Der Punkt zur hervorgehobenen Distanz. Gesucht wird binär, weil das
* Profil alle Originalpunkte enthält — bei jeder Mausbewegung linear
* durch mehrere tausend Einträge zu laufen wäre unnötig teuer.
*/
const hover = $derived.by(() => {
if (!stats || highlightDistance == null) return null
let lo = 0
let hi = elevation.length - 1
while (hi - lo > 1) {
const mid = (lo + hi) >> 1
if (elevation[mid].d <= highlightDistance) lo = mid
else hi = mid
}
const p =
Math.abs(elevation[lo].d - highlightDistance) <=
Math.abs(elevation[hi].d - highlightDistance)
? elevation[lo]
: elevation[hi]
return {
ele: p.ele,
d: p.d,
// In Prozent, damit die HTML-Overlays dieselbe Stelle treffen wie
// das über viewBox skalierte SVG.
left: (p.d / stats.totalD) * 100,
top: (1 - (p.ele - stats.min) / stats.span) * 100,
}
})
/** Distanz an der Mausposition; null, wenn es keine Punkte gibt. */
function distanceAt(e: MouseEvent): number | null {
if (!elevation.length) return null
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const ratio = (e.clientX - rect.left) / rect.width
const idx = Math.round(ratio * (elevation.length - 1))
const clamped = Math.max(0, Math.min(elevation.length - 1, idx))
return elevation[clamped].d
}
function handleMove(e: MouseEvent) {
const d = distanceAt(e)
if (d !== null) onhover?.(d)
}
function handleClick(e: MouseEvent) {
if (!onselect) return
const d = distanceAt(e)
if (d !== null) onselect(d)
}
function handleLeave() {
onhover?.(null)
}
/**
* Tastaturweg zum Setzen: Mit den Pfeiltasten die Stelle wählen, mit
* Enter oder Leertaste bestätigen. Ohne das wäre das Profil ein reines
* Mausziel.
*/
function handleKey(e: KeyboardEvent) {
if (!onselect || !stats) return
const total = stats.totalD
const step = total / 50
const current = highlightDistance ?? 0
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault()
const next = e.key === 'ArrowRight' ? current + step : current - step
onhover?.(Math.max(0, Math.min(total, next)))
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (highlightDistance != null) onselect(highlightDistance)
}
}
</script>
{#if stats}
<div class="relative w-full" style:height>
<!--
Die Fläche ist ein echter Knopf: Sie reagiert auf Zeiger und
Tastatur, und ein <svg> mit Rollen und tabindex bleibt für
Hilfsmittel ein Bild mit angeklebtem Verhalten.
-->
<button
type="button"
class="block w-full h-full border-0 bg-transparent p-0"
class:cursor-pointer={!!onselect}
aria-label={onselect
? 'Höhenprofil hier klicken setzt einen Marker an dieser Stelle'
: 'Höhenprofil'}
onmousemove={handleMove}
onmouseleave={handleLeave}
onclick={handleClick}
onkeydown={handleKey}
>
<svg
viewBox="0 0 {W} {H}"
preserveAspectRatio="none"
class="w-full h-full"
role="img"
aria-hidden="true"
>
<!-- Weiss zu 90 %, die Flaeche darunter blass. -->
<path
d={path}
fill="rgb(255 255 255 / 0.2)"
stroke="rgb(255 255 255 / 0.9)"
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
{#if hover}
<line
x1={(hover.left / 100) * W}
y1="0"
x2={(hover.left / 100) * W}
y2={H}
stroke="rgb(255 255 255 / 0.9)"
stroke-width="1"
vector-effect="non-scaling-stroke"
/>
{/if}
</svg>
</button>
{#if hover}
<!--
Punkt und Beschriftung liegen als HTML über dem SVG: dessen
viewBox wird ungleichmäßig gestreckt, ein Kreis darin würde
zur Ellipse und Text mitverzerrt.
-->
<span
class="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white ring-2 ring-background"
style:left="{hover.left}%"
style:top="{hover.top}%"
></span>
<span
class="pointer-events-none absolute top-0 -translate-x-1/2 rounded bg-background/90 px-1.5 py-0.5 text-xs font-medium whitespace-nowrap shadow-sm"
style:left="clamp(2.5rem, {hover.left}%, calc(100% - 2.5rem))"
>
{Math.round(hover.ele)} m · {(hover.d / 1000).toFixed(2)} km
</span>
{/if}
</div>
<div class="flex justify-between text-xs text-muted-foreground mt-1">
<span>{Math.round(stats.min)} m</span>
<span>{(stats.totalD / 1000).toFixed(1)} km</span>
<span>{Math.round(stats.max)} m</span>
</div>
{:else}
<p class="text-sm text-muted-foreground">Keine Höhendaten vorhanden.</p>
{/if}

View file

@ -0,0 +1,34 @@
<script lang="ts">
/**
* Rendert das Icon eines Flag-Typs. Der Name kommt aus der Datenbank und
* ist Freitext — solange geladen wird und bei unbekannten Namen steht
* ersatzweise die Flagge da, statt eine Lücke zu lassen.
*/
import { Flag } from 'lucide-svelte'
import { iconLoader } from '$lib/flagIcons'
let {
name,
color,
class: className = 'size-4',
}: { name?: string; color?: string; class?: string } = $props()
// Das Versprechen einmal je Name bilden, nicht bei jedem Rendern neu.
const loading = $derived(iconLoader(name)?.() ?? null)
</script>
<!-- Farbe über den Wrapper: Lucide zeichnet mit currentColor. -->
<span class="inline-flex shrink-0" style:color>
{#if loading}
{#await loading}
<Flag class={className} />
{:then mod}
{@const Icon = mod.default as typeof Flag}
<Icon class={className} />
{:catch}
<Flag class={className} />
{/await}
{:else}
<Flag class={className} />
{/if}
</span>

View file

@ -0,0 +1,31 @@
<script lang="ts">
/**
* Das App-Symbol: die Kurbel auf gelber Kachel — dasselbe Bild, das auf
* dem Startbildschirm und im Browser-Tab steht.
*
* Anders als <Mark> faerbt es sich nicht mit: Ein App-Symbol ist ein
* Gegenstand, kein Text. Es sieht in hell und dunkel gleich aus, sonst
* erkennt es niemand wieder.
*/
import { MARK } from '$lib/brand'
let { class: className = 'size-16' }: { class?: string } = $props()
/**
* Die Marke steht auf 84 %, damit sie nicht an die Rundung stoesst.
* Verkleinert wird um den Mittelpunkt der Flaeche, nicht um deren Ecke:
* erst das Zentrum halten (32 32·s), dann die Marke wie gehabt
* einruecken (dx·s). Ohne den ersten Summanden rutscht sie nach oben
* links aus der Kachel.
*/
const s = 0.84
const dx = $derived(32 - 32 * s + MARK.dx * s)
const dy = $derived(32 - 32 * s + MARK.dy * s)
</script>
<svg class={className} viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="14" fill="#eab308" />
<g transform="translate({dx} {dy}) scale({s})">
<path fill="#18181b" d={MARK.path} />
</g>
</svg>

View file

@ -0,0 +1,54 @@
<script lang="ts">
/**
* Icon-Auswahl: das Raster ist die Auswahl, nicht ein Vorschlag daneben.
*
* Frueher stand hier zusaetzlich ein Freitextfeld fuer jeden beliebigen
* Namen von lucide.dev. Das verlangte, einen Katalog von tausend Icons zu
* kennen und den Namen fehlerfrei abzutippen, und belohnte den Tippfehler
* mit einem leeren Kaestchen. Was zur Wahl steht, steht jetzt da.
*
* Gemeinsam genutzt von der Flag-Verwaltung und dem Marker-Dialog, damit
* beide dieselbe Auswahl zeigen.
*/
import FlagIcon from './FlagIcon.svelte'
import { tooltip } from '$lib/stores/app.svelte'
import { FLAG_ICON_SUGGESTIONS } from '$lib/flagIcons'
import { Label } from '@/components/ui/label'
let {
value = $bindable(''),
color = undefined,
id = 'icon-picker',
label = 'Icon',
}: {
value?: string
/** Faerbt die Icons im Raster, damit man das Ergebnis sieht. */
color?: string
id?: string
label?: string
} = $props()
</script>
<div class="space-y-2">
<Label id="{id}-label">{label}</Label>
<!-- Eine Gruppe von Knoepfen, kein Eingabefeld: benannt ueber
aria-labelledby statt ueber ein <label for>. -->
<div role="group" aria-labelledby="{id}-label" class="grid grid-cols-10 gap-1 rounded-lg border p-2">
{#each FLAG_ICON_SUGGESTIONS as name (name)}
<button
type="button"
aria-label={name}
use:tooltip={{ content: name }}
aria-pressed={value === name}
class="grid place-items-center rounded-md p-1.5 hover:bg-accent"
class:bg-accent={value === name}
class:ring-2={value === name}
class:ring-primary={value === name}
onclick={() => (value = name)}
>
<FlagIcon {name} {color} class="size-4" />
</button>
{/each}
</div>
</div>

View file

@ -1,41 +0,0 @@
<script lang="ts">
/**
* Rahmen für Impressum und Datenschutz: schmale Spalte, Fließtext,
* ein Weg zurück.
*
* Beide Seiten sind Prosa und sonst nichts — deshalb ein Bauteil und
* zwei dünne Routen, statt zweimal dieselbe Spalte zu bauen.
*/
import type { Snippet } from 'svelte'
let { title, updated, children }: { title: string; updated: string; children: Snippet } =
$props()
</script>
<svelte:head>
<title>{title} · Stammtisch Hersbruck</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div class="mx-auto max-w-2xl px-6 py-16">
<a
href="/"
class="text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
← Zurück
</a>
<h1 class="mt-6 text-2xl font-semibold tracking-tight">{title}</h1>
<p class="mt-1 text-sm text-muted-foreground">Stand: {updated}</p>
<!--
`prose` aus dem Typografie-Plugin: Ein Rechtstext ist eine Folge von
Überschriften und Absätzen, und die soll aussehen wie gesetzt und
nicht wie ein Formular.
-->
<div
class="prose prose-sm mt-8 max-w-none prose-headings:font-semibold prose-headings:tracking-tight prose-a:text-foreground"
>
{@render children()}
</div>
</div>

View file

@ -0,0 +1,36 @@
<script lang="ts">
/**
* Logo eines Teams. Ist eins hinterlegt, steht es allein; sonst faellt es
* auf das Zeichen der App zurueck.
*
* `icon` bestimmt, worauf: In der Kopfleiste steht das App-Symbol, die
* gelbe Kachel — dort ist das Logo ein anklickbarer Gegenstand neben
* anderen. Als Wasserzeichen und auf den Karten steht die blosse Marke,
* die sich mit der Textfarbe faerbt; eine Kachel waere dort ein grosses
* gelbes Viereck.
*
* Groesse ueber eine Klasse steuerbar, damit dasselbe Logo im Header
* (klein), auf der Login-Karte (gross) und als Wasserzeichen (riesig)
* verwendet werden kann.
*/
import { getFileURL } from '$lib/stores/pocketbase.svelte'
import type { TeamsResponse } from '$lib/types'
import Mark from './Mark.svelte'
import Icon from './Icon.svelte'
let {
class: className = 'w-24 h-24',
team = null,
icon = false,
}: { class?: string; team?: TeamsResponse | null; icon?: boolean } = $props()
const url = $derived(team?.logo ? getFileURL(team, team.logo) : null)
</script>
{#if url}
<img alt="" class="{className} object-contain" src={url} />
{:else if icon}
<Icon class={className} />
{:else}
<Mark class="{className} text-primary" />
{/if}

View file

@ -0,0 +1,21 @@
<script lang="ts">
/**
* Die Bildmarke allein: die Kurbel, in `currentColor`. Sie faerbt sich
* also ueber die Textfarbe — `class="text-primary"` macht sie gelb,
* im Wasserzeichen erbt sie einfach, was da ist.
*/
import { MARK } from '$lib/brand'
let { class: className = 'size-16' }: { class?: string } = $props()
</script>
<svg
class={className}
viewBox="0 0 64 64"
fill="none"
aria-hidden="true"
>
<g transform="translate({MARK.dx} {MARK.dy})">
<path fill="currentColor" d={MARK.path} />
</g>
</svg>

View file

@ -0,0 +1,243 @@
<script lang="ts">
/**
* Die Leiste über allen Dashboard-Seiten: alles, was den Zeitnehmer
* betrifft, an einer Stelle. Von oben nach unten mit fallender
* Dringlichkeit — fehlende Verbindung, nicht übertragene Zeiten, laufende
* Uhren.
*
* Gedacht für die Strecke: Man legt zwischen zwei Startern die Trails
* oder die Fahrerliste an, und die laufende Uhr bleibt trotzdem im Blick —
* samt Stopp ohne Umweg über die Stage-Seite.
*
* Aufgeklappt ist der Normalfall: Wer an der Strecke steht, will die
* laufenden Zeiten sehen und nicht erst danach fragen. Einklappen geht,
* sobald es mehr als eine ist. Bei genau einer entfällt das Klappen.
*
* Gestoppt wird auch von hier aus nicht direkt: Ein Klick auf die Zeile
* öffnet den Zeit-Dialog mit Fahrer und Stage vorbelegt.
*/
import { getTimeContext } from '$lib/stores/times.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getStageContext } from '$lib/stores/stages.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { timeOutbox } from '$lib/stores/timeOutbox.svelte'
import { connection } from '$lib/stores/connection.svelte'
import TimeDialog from './TimeDialog.svelte'
import { Button } from '@/components/ui/button'
import { ChevronDown, ChevronUp, CloudOff, RefreshCw, Timer, WifiOff } from 'lucide-svelte'
const times = getTimeContext()
const riders = getRiderContext()
const stages = getStageContext()
const participants = getEventParticipantContext()
const clock = ticker()
const running = $derived(times.allRunning(clock.now))
const single = $derived(running.length === 1)
let expanded = $state(true)
// Vorbelegung für den Zeit-Dialog
let timeDialog = $state(false)
let dialogEvent = $state('')
let dialogRider = $state<string | null>(null)
let dialogStage = $state<string | null>(null)
function openTimeDialog(riderId: string | undefined, stageId: string | undefined) {
const stage = stageId ? stages.getById(stageId) : undefined
if (!stage?.event) return
dialogEvent = stage.event
dialogRider = riderId ?? null
dialogStage = stage.id
timeDialog = true
}
// Eine einzelne Zeit steht immer offen da; bei mehreren entscheidet der
// Nutzer — voreingestellt ist offen. Fällt die Anzahl wieder auf eine,
// ist der Zustand egal.
const open = $derived(single || expanded)
/** Die Startnummer gilt je Event, deshalb der Umweg über die Stage. */
function riderLabel(riderId: string | undefined, stageId: string | undefined) {
const eventId = stageId ? stages.getById(stageId)?.event : undefined
if (!eventId) {
const r = riderId ? riders.getById(riderId) : undefined
return r ? riderName(r) : 'Unbekannter Fahrer'
}
return participants.riderLabel(eventId, riderId)
}
function stageLabel(stageId: string | undefined) {
const stage = stageId ? stages.getById(stageId) : undefined
return stage?.description || 'Stage'
}
/** „seit 3 min" — wie lange die Verbindung schon fehlt. */
function since(ms: number): string {
const min = Math.floor(ms / 60000)
if (min < 1) return 'gerade eben'
if (min < 60) return `seit ${min} min`
return `seit ${Math.floor(min / 60)} h`
}
</script>
{#if running.length > 0 || timeOutbox.pending > 0 || !connection.online}
<!-- fixed statt sticky: Die Leiste soll auch beim Scrollen stehen bleiben. -->
<div class="fixed bottom-4 right-4 z-50 w-[min(24rem,calc(100vw-2rem))]">
<div class="rounded-lg border bg-card text-card-foreground shadow-lg overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 border-b bg-primary/5">
{#if running.length > 0}
<span class="relative flex size-2 shrink-0">
<span class="absolute inline-flex h-full w-full rounded-full bg-primary opacity-60 animate-ping"></span>
<span class="relative inline-flex size-2 rounded-full bg-primary"></span>
</span>
{/if}
<span class="text-sm font-medium">
{#if running.length === 0}
Zeitnahme
{:else if running.length === 1}
Zeit läuft
{:else}
{running.length} Zeiten laufen
{/if}
</span>
{#if !open}
<span class="text-sm tabular-nums font-semibold ml-auto">
{running[0].formattedTime}
</span>
{/if}
{#if !single}
<Button
variant="ghost"
size="icon"
class="ml-auto size-7 shrink-0"
tooltip={expanded ? 'Einklappen' : 'Alle anzeigen'}
onclick={() => (expanded = !expanded)}
>
{#if expanded}
<ChevronDown class="size-4" />
{:else}
<ChevronUp class="size-4" />
{/if}
</Button>
{/if}
</div>
<!--
Die fehlende Verbindung steht ueber allem: Sie ist der Grund,
aus dem die Zeiten darunter liegenbleiben. Die Meldung geht
erst, wenn der Server wieder antwortet — an der Strecke waeren
fuenf Sekunden Einblendung dieselbe Katastrophe wie gar keine.
-->
{#if !connection.online}
<div class="flex items-start gap-2 border-b bg-amber-500/15 px-3 py-2">
<WifiOff class="mt-0.5 size-4 shrink-0 text-amber-600" />
<div class="min-w-0 flex-1">
<p class="text-xs font-medium">
Keine Verbindung
{#if connection.offlineFor !== null}
<span class="font-normal text-muted-foreground">
({since(connection.offlineFor)})
</span>
{/if}
</p>
<p class="text-xs text-muted-foreground">
Die Zeitnahme läuft weiter. Gestoppt wird auf diesem Gerät,
übertragen, sobald der Server wieder antwortet.
</p>
</div>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0"
tooltip="Verbindung prüfen"
disabled={connection.checking}
onclick={() => connection.check()}
>
<RefreshCw class="size-4 {connection.checking ? 'animate-spin' : ''}" />
</Button>
</div>
{/if}
<!--
Nicht uebertragene Zeiten darunter: Eine Zeit, die nur auf
diesem Geraet existiert, ist das Naechstdringende.
-->
{#if timeOutbox.pending > 0}
<div class="flex items-center gap-2 border-b bg-amber-500/10 px-3 py-2">
<CloudOff class="size-4 shrink-0 text-amber-600" />
<span class="min-w-0 flex-1 text-xs">
{timeOutbox.pending}
{timeOutbox.pending === 1 ? 'Zeit liegt' : 'Zeiten liegen'} nur auf diesem
Gerät.
{#if timeOutbox.error}
<span class="block text-destructive">{timeOutbox.error}</span>
{/if}
</span>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0"
tooltip="Jetzt übertragen"
disabled={timeOutbox.flushing}
onclick={() => timeOutbox.flush()}
>
<RefreshCw class="size-4 {timeOutbox.flushing ? 'animate-spin' : ''}" />
</Button>
</div>
{/if}
{#if open}
<ul class="divide-y max-h-[50vh] overflow-y-auto">
{#each running as t (t.id)}
<li>
<!--
Die ganze Zeile ist der Knopf, nicht ein Icon
an ihrem Rand: An der Strecke wird mit dem
Daumen bedient, und ein 16-px-Ziel neben einer
fingerbreiten Zeile ist eine Einladung zum
Danebentippen.
Der Name der Stage verlinkt deshalb nicht mehr
auf ihre Seite — ein Link im Knopf waere weder
gueltiges HTML noch eindeutig zu treffen.
-->
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-accent"
onclick={() => openTimeDialog(t.rider, t.stage)}
>
<Timer class="size-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">
{riderLabel(t.rider, t.stage)}
</span>
<span class="block truncate text-xs text-muted-foreground">
{stageLabel(t.stage)}
</span>
</span>
<span class="shrink-0 text-lg font-semibold tabular-nums">
{t.formattedTime}
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<TimeDialog
bind:open={timeDialog}
eventId={dialogEvent}
riderId={dialogRider}
stageId={dialogStage}
/>
{/if}

View file

@ -0,0 +1,39 @@
<script lang="ts">
/**
* Ladeanzeige als Mountainbike-Laufrad: Stollenreifen, Felge, gekreuzte
* Speichen, Nabe. Das Ventil sitzt bewusst nur an einer Stelle — ohne diese
* Unwucht sieht ein achtfach symmetrisches Rad im Stillstand aus, egal wie
* schnell es sich dreht.
*
* Zeichnet in `currentColor` und richtet sich nach der uebergebenen
* Groessenklasse, laesst sich also wie ein Lucide-Icon einsetzen.
*/
let { class: className = 'size-6' }: { class?: string } = $props()
</script>
<svg
aria-hidden="true"
class="animate-spin {className}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Stollen: 14 Segmente auf dem Umfang 2πr = 60.95, also je 4.35 breit -->
<circle cx="12" cy="12" r="9.7" stroke-dasharray="1.7 2.654" stroke-width="2.6" />
<!-- Reifen -->
<circle cx="12" cy="12" r="9.4" stroke-width="1.8" />
<!-- Felge -->
<circle cx="12" cy="12" r="7.3" stroke-width="1.1" />
<!-- Speichen, gekreuzt gespannt wie an einem echten Laufrad -->
<path
d="M12.88 10.2L12 4.7M12.65 10.11L17.16 6.84M13.8 12.88L19.3 12M13.89 12.65L17.16 17.16M11.12 13.8L12 19.3M11.35 13.89L6.84 17.16M10.2 11.12L4.7 12M10.11 11.35L6.84 6.84"
opacity=".5"
stroke-linecap="round"
stroke-width=".8"
/>
<!-- Nabe -->
<circle cx="12" cy="12" fill="currentColor" r="1.9" stroke="none" />
<!-- Ventil -->
<path d="M12 8.4L12 6.6" stroke-linecap="round" stroke-width="1.3" />
</svg>

View file

@ -0,0 +1,101 @@
<script lang="ts">
/**
* Der Menuepunkt „Team" in der Kopfleiste — ein Menuepunkt, der sich beim
* Ueberfahren aufklappt: darunter der Wechsel zwischen den eigenen Teams
* und der Weg zu einem neuen.
*
* Traegt bits-uis NavigationMenu und nicht das DropdownMenu. Ein Dropdown
* ist ein Menue, das auf Ansage aufgeht; eine Navigationsleiste ist etwas
* anderes, und die Unterschiede sind genau die, die hier gebraucht werden:
* Aufklappen beim Ueberfahren samt Verzoegerung, ein Klick, der nach dem
* Hover-Aufklappen nicht wieder zuklappt, kein Fokusfang, keine
* Scrollsperre, kein Fokusring nach dem Zeigerbesuch. Das alles stand hier
* vorher als Handarbeit — Zeitgeber, ueberschriebene Zeigerhandler,
* unterdrueckter Fokus. Die Komponente kann es von sich aus.
*
* Der Punkt selbst bleibt ein Link auf die Teamseite: Der Trigger wird
* ueber das child-Snippet als <a> gezeichnet. bits-ui haelt seinen eigenen
* onclick zurueck, wenn per Zeiger aufgeklappt wurde („if opened via
* pointer move, we prevent the click event"), der Klick landet also beim
* Link.
*
* Auf dem Touchscreen gibt es kein Ueberfahren: Dort fuehrt der Tipp auf
* die Teamseite, und gewechselt wird in deren Liste.
*/
import { goto } from '$app/navigation'
import { getTeamContext } from '$lib/stores/teams.svelte'
import CreateTeamDialog from '$lib/components/CreateTeamDialog.svelte'
import * as NavigationMenu from '@/components/ui/navigation-menu'
import { ChevronDown, Plus, Users } from 'lucide-svelte'
let {
class: className = '',
onnavigate,
}: { class?: string; onnavigate?: () => void } = $props()
const teams = getTeamContext()
let createDialog = $state(false)
/**
* Team wechseln heisst hier auch: hinsehen. Der Wechsel allein aendert
* bloss still, was jede Liste zeigt — die Teamseite macht sichtbar,
* worauf man gewechselt hat.
*/
function switchTo(id: string) {
teams.setActive(id)
goto('/dashboard/team')
onnavigate?.()
}
const itemClass =
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-left transition-colors hover:bg-accent'
</script>
<NavigationMenu.Item value="team">
<NavigationMenu.Trigger>
{#snippet child({ props })}
<a {...props} href="/dashboard/team" class={className} onclick={() => onnavigate?.()}>
<Users class="h-4 w-4" />
<span>Team</span>
<ChevronDown class="h-3 w-3 opacity-50" />
</a>
{/snippet}
</NavigationMenu.Trigger>
<!--
mt-0 statt der 1.5 aus dem Standard: Der Abstand war ein toter Streifen
zwischen Menuepunkt und Panel. Wer den Zeiger hinunterbewegte, verliess
beides zugleich, und nach delayDuration klappte das Menue zu, bevor ein
Eintrag zu treffen war. Ein Hover-Menue darf keine Luecke haben.
-->
<NavigationMenu.Content class="min-w-56 group-data-[viewport=false]/navigation-menu:mt-0">
{#each teams.records as t (t.id)}
<button
type="button"
onclick={() => switchTo(t.id)}
class="{itemClass} {t.id === teams.activeId ? 'bg-accent' : ''}"
>
<span class="flex-1 truncate">{t.name}</span>
{#if teams.isOwner(t)}
<span class="text-xs text-muted-foreground">Owner</span>
{:else if teams.isAdmin(t)}
<span class="text-xs text-muted-foreground">Admin</span>
{/if}
</button>
{:else}
<p class="px-2 py-1.5 text-sm text-muted-foreground">Noch in keinem Team</p>
{/each}
<div class="my-1 h-px bg-border"></div>
<button type="button" onclick={() => (createDialog = true)} class={itemClass}>
<Plus class="h-4 w-4" />
Team erstellen
</button>
</NavigationMenu.Content>
</NavigationMenu.Item>
<!-- Ausserhalb des Menues: mit dem Klick klappt es zu, und ein darin
gerendeter Dialog verschwaende mit ihm. -->
<CreateTeamDialog bind:open={createDialog} />

View file

@ -0,0 +1,281 @@
<script lang="ts">
/**
* Der eine Dialog für die Zeitnahme. Fahrer und Stage stehen immer beide
* darin — auch wenn einer davon aus dem Kontext vorbelegt ist —, damit
* beim Starten und Stoppen nie ein Zweifel bleibt, wen und was man
* gerade erwischt. Start und Stopp passieren ausschließlich hier: weder
* Tabellen noch die Toast-Leiste lösen sie direkt aus.
*
* Bedient wird das an der Strecke, im Stehen, mit einer Hand am Lenker
* und einem Daumen am Telefon. Danach ist alles hier bemessen: Der Fahrer
* wird aus Kacheln gewählt statt aus einer Auswahlliste — die Startnummer
* gross wie am Trikot, der Name darunter —, und Starten und Stoppen sind
* flächige Knöpfe und keine Zeile im Fussbereich.
*/
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getStageContext } from '$lib/stores/stages.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import * as Select from '@/components/ui/select'
import { AlertTriangle, Flag, Play, Square } from 'lucide-svelte'
let {
open = $bindable(false),
eventId,
riderId = null,
stageId = null,
}: {
open?: boolean
/** Event, aus dem Fahrer und Stages zur Auswahl stehen. */
eventId: string
/** Vorbelegung aus dem Kontext; bleibt im Dialog änderbar. */
riderId?: string | null
stageId?: string | null
} = $props()
const times = getTimeContext()
const riders = getRiderContext()
const stages = getStageContext()
const participants = getEventParticipantContext()
const clock = ticker()
let selectedRider = $state('')
let selectedStage = $state('')
let error = $state<string | null>(null)
let confirmDnf = $state(false)
let busy = $state(false)
// Beim Öffnen auf die Vorbelegung zurücksetzen — ein Dialog, der noch die
// Auswahl vom letzten Mal zeigt, ist an der Strecke eine Fehlerquelle.
$effect(() => {
if (!open) return
selectedRider = riderId ?? ''
selectedStage = stageId ?? ''
error = null
confirmDnf = false
})
// Zur Auswahl steht, wer beim Event zugesagt hat — nicht der ganze Kader.
const eventRiders = $derived(
eventId
? participants
.confirmedByEvent(eventId)
.map((p) => riders.getById(p.rider))
.filter((r) => !!r)
: [],
)
const eventStages = $derived(eventId ? stages.getByEvent(eventId) : [])
const stageLabel = $derived(
selectedStage ? stages.getById(selectedStage)?.description ?? 'Stage wählen…' : 'Stage wählen…',
)
const ready = $derived(!!selectedRider && !!selectedStage)
// Über die laufende Liste gelesen, damit die Uhr im Dialog mitzählt und
// verschwindet, sobald jemand anders dieselbe Zeit stoppt.
const active = $derived(
ready
? times
.allRunning(clock.now)
.find((t) => t.stage === selectedStage && t.rider === selectedRider)
: undefined,
)
/** Bereits gewertete Zeit derselben Paarung — Hinweis, keine Sperre. */
const existing = $derived(
ready && !active
? times.getByStage(selectedStage).find((t) => t.rider === selectedRider && t.stop)
: undefined,
)
async function start() {
if (!ready || busy) return
busy = true
error = null
try {
await times.start(selectedStage, selectedRider)
open = false
} catch (e: any) {
error = e.message ?? 'Die Zeit konnte nicht gestartet werden.'
} finally {
busy = false
}
}
async function stop(status: 'finished' | 'dnf') {
if (!active || busy) return
busy = true
error = null
try {
await times.stop(active.id, status as TimeStatus)
open = false
} catch (e: any) {
error = e.message ?? 'Die Zeit konnte nicht gestoppt werden.'
} finally {
busy = false
confirmDnf = false
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Zeit erfassen</Dialog.Title>
<Dialog.Description>
Fahrer und Stage wählen — je nach Stand wird gestartet oder gestoppt.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label id="rider-label">Fahrer</Label>
{#if eventRiders.length === 0}
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Fahrer.</p>
{:else}
<!--
Kacheln statt Auswahlliste: Eine Liste verlangt
aufklappen, zielen und treffen — drei Schritte für
etwas, das jeder Zeitnehmer zwanzigmal am Tag macht.
Hier steht jeder Fahrer als eigene Fläche da, gross
genug für einen Daumen.
-->
<div
role="radiogroup"
aria-labelledby="rider-label"
class="grid max-h-56 grid-cols-3 gap-2 overflow-y-auto rounded-lg border p-2 sm:grid-cols-4"
>
{#each eventRiders as r (r.id)}
{@const number = participants.numberOf(eventId, r.id)}
{@const chosen = selectedRider === r.id}
<button
type="button"
role="radio"
aria-checked={chosen}
onclick={() => (selectedRider = r.id)}
class="flex min-h-20 flex-col items-center justify-center gap-0.5 rounded-lg border p-2 transition-colors hover:bg-accent"
class:border-primary={chosen}
class:bg-primary={chosen}
class:text-primary-foreground={chosen}
>
<span class="font-startnumber text-2xl leading-none">
{number || ''}
</span>
<span class="line-clamp-2 text-center text-xs leading-tight">
{riderName(r)}
</span>
</button>
{/each}
</div>
{/if}
</div>
<div class="space-y-2">
<Label>Stage</Label>
<Select.Root type="single" bind:value={selectedStage} disabled={eventStages.length === 0}>
<Select.Trigger class="w-full">{stageLabel}</Select.Trigger>
<Select.Content>
{#each eventStages as r (r.id)}
<Select.Item value={r.id}>{r.description || 'Stage'}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if eventStages.length === 0}
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Stages.</p>
{/if}
</div>
{#if active}
<div class="rounded-lg border bg-primary/5 py-4 text-center">
<p class="text-xs text-muted-foreground">läuft seit</p>
<p class="font-mono text-4xl font-semibold tabular-nums">{active.formattedTime}</p>
</div>
{:else if existing}
<p class="text-sm text-muted-foreground">
Für diese Paarung gibt es bereits eine Zeit ({existing.formattedTime}).
Ein neuer Start legt eine zweite an.
</p>
{/if}
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
{#if confirmDnf}
<!-- DNF beendet die Zeit ohne Wertung und ist nicht zurückzunehmen. -->
<div class="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-3">
<p class="text-sm flex items-start gap-2">
<AlertTriangle class="size-4 text-destructive shrink-0 mt-0.5" />
<span>
Wirklich als DNF werten? Die Zeit wird beendet und taucht in keiner
Rangliste auf.
</span>
</p>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="sm" onclick={() => (confirmDnf = false)}>
Zurück
</Button>
<Button variant="destructive" size="sm" disabled={busy} onclick={() => stop('dnf')}>
DNF bestätigen
</Button>
</div>
</div>
{:else}
<!--
Die Handlung steht als Fläche da, nicht als Zeile im
Fussbereich: 96 px hoch, über die volle Breite, Symbol über
Wort. Wer im Stehen auf die Sekunde drückt, zielt nicht auf
einen 36 px hohen Streifen am unteren Rand.
DNF steht daneben und ist schmaler — dieselbe Höhe, damit es
sich treffen lässt, aber nicht dieselbe Breite, damit es nicht
versehentlich getroffen wird.
-->
{#if active}
<div class="grid grid-cols-3 gap-2">
<Button
variant="destructive"
disabled={busy}
class="col-span-2 h-24 flex-col gap-1 text-lg font-semibold"
onclick={() => stop('finished')}
>
<Square class="size-7" />
Stoppen
</Button>
<Button
variant="outline"
disabled={busy}
class="h-24 flex-col gap-1 text-base font-semibold"
onclick={() => (confirmDnf = true)}
>
<Flag class="size-6" />
DNF
</Button>
</div>
{:else}
<Button
disabled={!ready || busy}
class="h-24 w-full flex-col gap-1 text-lg font-semibold"
onclick={start}
>
<Play class="size-7" />
Starten
</Button>
{/if}
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>

View file

@ -0,0 +1,494 @@
<script lang="ts">
/**
* Karte für einen Trail. Bewusst ohne Store-Zugriff: Alles kommt über
* Props, Ereignisse gehen über Callback-Props zurück. Damit ist die
* Komponente in Liste, Detailseite und Setzmodus gleichermaßen nutzbar.
*/
import { mount, onMount, unmount } from 'svelte'
import * as maplibregl from 'maplibre-gl'
import type { Map as MapLibreMap, Marker, Popup } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
// MapLibre 6 liegt als drei Dateien vor (Hauptdatei, shared, worker) und
// lädt den Worker zur Laufzeit relativ zum eigenen Verzeichnis nach. Beides
// — Vites Dependency-Optimierung im Dev-Modus wie der Produktionsbuild —
// bringt den Worker nicht an eine Stelle, an der er seine Nachbardatei
// maplibre-gl-shared.mjs findet. Der Worker startet dann zwar, sein
// Import läuft ins Leere, und er antwortet auf keine Nachricht mehr.
// Folge: GeoJSON-Quellen bleiben dauerhaft im Ladezustand, es wird keine
// Trail-Linie gezeichnet. Rasterkacheln brauchen keinen Worker, deshalb
// erscheint die Karte trotzdem und der Fehler sieht nach fehlenden Daten
// aus.
// ?worker&url bündelt den Worker samt Abhängigkeiten zu einer eigenständigen
// Datei — ein blosses ?url würde nur die Rohdatei kopieren und ihren
// relativen Import auf ./maplibre-gl-shared.mjs unaufgelöst lassen (404).
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
import { nearestOnLine, distanceAlong, positionAtDistance } from '$lib/gpx'
import FlagIcon from './FlagIcon.svelte'
import { trailStatusColor } from '$lib/trailStatus'
import { mode } from 'mode-watcher'
maplibregl.setWorkerUrl(workerUrl)
type LineGeoJSON = { type: 'LineString'; coordinates: [number, number][] }
type MarkerData = {
id: string
lat: number
lng: number
color: string
resolved: boolean
/** Was — der Flag-Typ */
label: string
/** Icon-Name des Flag-Typs */
icon?: string
/** Wann — bereits formatiert */
when?: string
/** Wer — bereits aufgelöst */
who?: string
note?: string
}
let {
geojson = null,
bounds = null,
markers = [],
status = 'offen',
coordDistances = [],
highlightDistance = null,
onplace,
ontrailhover,
controls = true,
height = '420px',
}: {
geojson?: LineGeoJSON | null
bounds?: [[number, number], [number, number]] | null
markers?: MarkerData[]
status?: 'offen' | 'eingeschraenkt' | 'gesperrt'
coordDistances?: number[]
highlightDistance?: number | null
/**
* Wird nur beim Klick auf die Trail-Linie gerufen — und zwar mit der
* darauf projizierten Stelle, nie mit dem rohen Klickpunkt. Ist der
* Callback nicht gesetzt, ist die Linie nicht klickbar.
*/
onplace?: (p: { lat: number; lng: number; distance: number | null }) => void
/** Streckenkilometer unter dem Zeiger, null beim Verlassen der Linie. */
ontrailhover?: (distance: number | null) => void
/**
* Zoom-Knöpfe, Maßstab und Quellenangabe. In der Kachelvorschau
* abgeschaltet — dort ist die Karte Bild, nicht Werkzeug. Die
* Quellenangabe zu OpenStreetMap trägt die große Karte.
*/
controls?: boolean
height?: string
} = $props()
/**
* Basiskarten von OpenFreeMap: zurückhaltend gezeichnet — die Trail-Linie
* liegt dadurch deutlich sichtbarer darüber — und in einer echten dunklen
* Fassung. Ein nachträglich abgedunkeltes OSM-Bild wäre nur ein dunkleres
* Bild, keine dunkle Karte.
*
* Vorher lagen hier die Rasterkacheln von CARTO. Die kommen inzwischen
* mit einem eingebrannten „API KEY REQUIRED" quer über der Karte — mit
* HTTP 200, weshalb es wie ein Darstellungsfehler aussah und nicht wie
* eine Sperre. Der Schlüssel aus PUBLIC_CARTO_API_KEY änderte daran
* nichts: Die Kachel kam byteidentisch zurück wie ohne ihn.
*
* OpenFreeMap braucht keinen Schlüssel und kennt kein Limit; die Stile
* heißen dort positron und dark und sind genau die, die CARTO als
* Positron und Dark Matter geprägt hat. Die Quellenangabe steckt in der
* Stildatei, sie muss hier nicht mehr von Hand gesetzt werden.
*/
const STYLE_LIGHT = 'https://tiles.openfreemap.org/styles/positron'
const STYLE_DARK = 'https://tiles.openfreemap.org/styles/dark'
const isDark = $derived(mode.current === 'dark')
// Fallback, wenn ein Trail noch keine Version hat: Hersbruck.
const FALLBACK_CENTER: [number, number] = [11.4318, 49.5089]
let container: HTMLDivElement
let map = $state<MapLibreMap | null>(null)
let ready = $state(false)
let markerObjects: Marker[] = []
let highlightMarker: Marker | null = null
let popup: Popup | null = null
/**
* MapLibre will für einen Marker ein DOM-Element, Icons kommen aber als
* Svelte-Komponenten. `mount` schlägt die Brücke — dafür müssen die
* Instanzen von Hand wieder abgeräumt werden, sonst bleiben sie beim
* Neuaufbau der Marker hängen.
*/
let iconInstances: Record<string, unknown>[] = []
function mountIcon(target: HTMLElement, name: string | undefined, size: string) {
iconInstances.push(mount(FlagIcon, { target, props: { name, class: size } }))
}
function clearIcons() {
for (const inst of iconInstances) unmount(inst)
iconInstances = []
}
onMount(() => {
let cleanupMap: (() => void) | null = null
function build() {
const instance = new maplibregl.Map({
container,
style: isDark ? STYLE_DARK : STYLE_LIGHT,
center: FALLBACK_CENTER,
zoom: 11,
attributionControl: controls ? undefined : false,
})
map = instance
if (controls) {
instance.addControl(new maplibregl.NavigationControl(), 'top-right')
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
}
// `style.load` statt `load`: Es feuert auch nach jedem
// setStyle. Ein Stilwechsel wirft Quellen und Layer weg, die
// Trail-Linie muss danach neu entstehen — die Marker nicht, die
// haengen als DOM-Elemente an der Karte und nicht am Stil.
instance.on('style.load', () => {
ready = true
drawTrail(instance)
})
cleanupMap = () => {
// Map.remove() räumt Controls auf, aber keine Marker — die tragen
// eigene move-Listener auf der Karte und werden hier zuerst entfernt.
for (const m of markerObjects) m.remove()
markerObjects = []
highlightMarker?.remove()
highlightMarker = null
popup?.remove()
popup = null
clearIcons()
instance.remove()
map = null
ready = false
}
}
// Browser begrenzen WebGL-Kontexte auf rund 8 bis 16. In einer Liste
// mit vielen Trails wären das zu viele — deshalb entsteht die Karte
// erst, wenn ihre Kachel in den Sichtbereich scrollt.
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !map) build()
}
},
{ rootMargin: '200px' },
)
observer.observe(container)
return () => {
observer.disconnect()
cleanupMap?.()
}
})
/**
* Hover und Klick hängen an einer unsichtbaren, breiten Kopie der Linie:
* die sichtbaren 4 px wären als Ziel zu schmal, und maplibre liefert
* layerbezogene Ereignisse nur für das, was wirklich getroffen wurde.
* Deshalb landet ein Klick neben dem Trail auch nirgends — Marker können
* gar nicht abseits der Strecke entstehen.
*
* Registriert wird einmalig direkt nach dem Anlegen der Layer; vorher
* hätten die layerbezogenen Listener kein Ziel.
*/
function bindTrailEvents(instance: MapLibreMap) {
const project = (e: maplibregl.MapMouseEvent) => {
const coords = geojson?.coordinates
if (!coords?.length) return null
const pos = nearestOnLine(coords, e.lngLat.lng, e.lngLat.lat)
if (!pos) return null
return { pos, distance: distanceAlong(pos, coordDistances) }
}
instance.on('mousemove', 'trail-hit', (e) => {
const hit = project(e)
if (!hit) return
instance.getCanvas().style.cursor = onplace ? 'pointer' : ''
ontrailhover?.(hit.distance)
})
instance.on('mouseleave', 'trail-hit', () => {
instance.getCanvas().style.cursor = ''
ontrailhover?.(null)
})
instance.on('click', 'trail-hit', (e) => {
if (!onplace) return
const hit = project(e)
if (!hit) return
// Ein offenes Marker-Popup gehört zur vorherigen Stelle.
popup?.remove()
onplace({ lat: hit.pos.lat, lng: hit.pos.lng, distance: hit.distance })
})
}
/**
* Die layerbezogenen Listener bleiben am Kartenobjekt haengen, auch wenn
* ihr Layer beim Stilwechsel verschwindet und gleich darauf wieder
* entsteht. Ein zweites bindTrailEvents wuerde sie deshalb nur
* verdoppeln.
*/
let trailEventsBound = false
/**
* Trail-Linie zeichnen bzw. aktualisieren. Zwei Aufrufer: der Effekt, wenn
* sich Strecke oder Zustand aendern, und `style.load` nach jedem
* Stilwechsel — dann ist die Quelle fort und muss neu angelegt werden.
*/
function drawTrail(instance: MapLibreMap) {
const data = geojson
const color = trailStatusColor(status)
const src = instance.getSource('trail') as maplibregl.GeoJSONSource | undefined
if (!data) {
src?.setData({ type: 'FeatureCollection', features: [] })
return
}
const feature = { type: 'Feature' as const, properties: {}, geometry: data }
if (src) {
src.setData(feature)
instance.setPaintProperty('trail-line', 'line-color', color)
return
}
instance.addSource('trail', { type: 'geojson', data: feature })
instance.addLayer({
id: 'trail-line',
type: 'line',
source: 'trail',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': color, 'line-width': 4 },
})
instance.addLayer({
id: 'trail-hit',
type: 'line',
source: 'trail',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': color, 'line-width': 20, 'line-opacity': 0 },
})
if (!trailEventsBound) {
bindTrailEvents(instance)
trailEventsBound = true
}
}
// Trail-Linie zeichnen bzw. aktualisieren
$effect(() => {
if (!map || !ready) return
drawTrail(map)
})
/**
* Beim Themenwechsel den ganzen Stil tauschen. Kacheln allein tun es
* nicht: Hell und dunkel unterscheiden sich hier nicht im Bild, sondern
* in der Zeichenvorschrift.
*/
$effect(() => {
if (!map || !ready) return
map.setStyle(isDark ? STYLE_DARK : STYLE_LIGHT)
})
// Kartenausschnitt auf den Track setzen
$effect(() => {
if (!map || !ready || !bounds) return
map.fitBounds(bounds, { padding: 40, duration: 400, maxZoom: 16 })
})
/** Popup-Inhalt zu einem Marker: was, wann, wer — plus Notiz. */
function popupContent(data: MarkerData): HTMLElement {
const root = document.createElement('div')
root.className = 'trail-popup'
const head = document.createElement('div')
head.className = 'trail-popup-head'
const icon = document.createElement('span')
icon.className = 'trail-popup-icon'
icon.style.color = data.color
mountIcon(icon, data.icon, 'size-4')
head.appendChild(icon)
const title = document.createElement('strong')
// textContent, nicht innerHTML: Label und Notiz sind Nutzereingaben.
title.textContent = data.label
head.appendChild(title)
if (data.resolved) {
const badge = document.createElement('span')
badge.className = 'trail-popup-badge'
badge.textContent = 'erledigt'
head.appendChild(badge)
}
root.appendChild(head)
if (data.note) {
const note = document.createElement('p')
note.className = 'trail-popup-note'
note.textContent = data.note
root.appendChild(note)
}
const meta = document.createElement('p')
meta.className = 'trail-popup-meta'
meta.textContent = [data.when, data.who].filter(Boolean).join(' · ')
if (meta.textContent) root.appendChild(meta)
return root
}
// Marker neu aufbauen — bei überschaubaren Mengen einfacher und
// zuverlässiger als einzelne Aktualisierungen.
$effect(() => {
if (!map || !ready) return
const instance = map
for (const m of markerObjects) m.remove()
markerObjects = []
clearIcons()
for (const data of markers) {
const el = document.createElement('button')
el.type = 'button'
el.title = data.label
el.setAttribute('aria-label', data.label)
el.style.cssText = `
width: 26px; height: 26px; border-radius: 9999px;
border: 2px solid white; cursor: pointer; padding: 0;
display: grid; place-items: center; color: white;
background: ${data.color};
opacity: ${data.resolved ? '0.35' : '1'};
box-shadow: 0 1px 4px rgba(0,0,0,.4);
`
mountIcon(el, data.icon, 'size-3.5')
// Der Klick bleibt beim Marker: Er darf kein neues Marker-Fenster
// öffnen, sondern zeigt nur, was hier schon gemeldet wurde.
el.addEventListener('click', (ev) => {
ev.stopPropagation()
popup?.remove()
popup = new maplibregl.Popup({
offset: 14,
closeButton: false,
className: 'trail-popup-shell',
})
.setLngLat([data.lng, data.lat])
.setDOMContent(popupContent(data))
.addTo(instance)
})
el.addEventListener('focus', () => {
el.style.outline = '2px solid #0ea5e9'
el.style.outlineOffset = '2px'
})
el.addEventListener('blur', () => {
el.style.outline = 'none'
})
markerObjects.push(
new maplibregl.Marker({ element: el })
.setLngLat([data.lng, data.lat])
.addTo(instance),
)
}
})
// Stelle aus dem Höhenprofil hervorheben. Die Kopplung läuft über die
// Distanz, nicht über den Index: elevation hat einen Eintrag pro
// Originalpunkt, coordinates nur die vereinfachten.
$effect(() => {
if (!map || !ready) return
highlightMarker?.remove()
highlightMarker = null
const coords = geojson?.coordinates
if (highlightDistance == null || !coords?.length) return
const at = positionAtDistance(coords, coordDistances, highlightDistance)
if (!at) return
const el = document.createElement('div')
el.style.cssText = `
width: 12px; height: 12px; border-radius: 9999px;
background: #0ea5e9; border: 2px solid white;
box-shadow: 0 1px 4px rgba(0,0,0,.4);
`
highlightMarker = new maplibregl.Marker({ element: el }).setLngLat(at).addTo(map)
})
</script>
<div
bind:this={container}
style:height
class="w-full rounded-lg overflow-hidden border"
></div>
<!--
Popups hängt maplibre selbst in den Karten-Container, deshalb greifen die
Regeln nur global. Bewusst helle Fläche wie das maplibre-Standardpopup —
im Dunkelmodus liegt es trotzdem lesbar über der hellen Karte.
-->
<style>
:global(.trail-popup-shell .maplibregl-popup-content) {
padding: 0.6rem 0.75rem;
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.18);
font-family: inherit;
max-width: 15rem;
}
:global(.trail-popup-head) {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.875rem;
color: #0f172a;
}
:global(.trail-popup-icon) {
display: inline-flex;
flex-shrink: 0;
}
:global(.trail-popup-badge) {
font-size: 0.6875rem;
padding: 0 0.35rem;
border-radius: 9999px;
background: #e2e8f0;
color: #475569;
}
:global(.trail-popup-note) {
margin: 0.35rem 0 0;
font-size: 0.8125rem;
color: #334155;
}
:global(.trail-popup-meta) {
margin: 0.35rem 0 0;
font-size: 0.75rem;
color: #64748b;
}
</style>

View file

@ -0,0 +1,31 @@
<script lang="ts">
/**
* Marke und Schriftzug nebeneinander — das grosse Logo fuer Login,
* Einladung und alles, wo der Name mitkommen soll.
*
* Die Hoehe steuert eine Klasse (`h-10`), die Breite ergibt sich aus der
* viewBox. Die Kurbel steht in der Akzentfarbe, der Schriftzug in der
* Textfarbe — beides Tokens, damit hell und dunkel von selbst stimmen.
*/
import { LOCKUP, MARK } from '$lib/brand'
let {
class: className = 'h-10',
title = 'Kurbeler',
}: { class?: string; title?: string } = $props()
</script>
<svg
class="w-auto {className}"
viewBox={LOCKUP.viewBox}
fill="none"
role="img"
aria-label={title}
>
<path class="fill-primary" d={MARK.path} />
<g
transform="translate({LOCKUP.x} {LOCKUP.y}) scale({LOCKUP.scale})"
>
<path fill="currentColor" d={LOCKUP.word} />
</g>
</svg>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
</script>
<AccordionPrimitive.Content
bind:ref
data-slot="accordion-content"
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...restProps}
>
<div class={cn("pb-4 pt-0", className)}>
{@render children?.()}
</div>
</AccordionPrimitive.Content>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AccordionPrimitive.ItemProps = $props();
</script>
<AccordionPrimitive.Item
bind:ref
data-slot="accordion-item"
class={cn("border-b last:border-b-0", className)}
{...restProps}
/>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
level = 3,
children,
...restProps
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
level?: AccordionPrimitive.HeaderProps["level"];
} = $props();
</script>
<AccordionPrimitive.Header {level} class="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
bind:ref
class={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium outline-none transition-all hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: AccordionPrimitive.RootProps = $props();
</script>
<AccordionPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="accordion"
{...restProps}
/>

View file

@ -0,0 +1,16 @@
import Root from "./accordion.svelte";
import Content from "./accordion-content.svelte";
import Item from "./accordion-item.svelte";
import Trigger from "./accordion-trigger.svelte";
export {
Root,
Content,
Item,
Trigger,
//
Root as Accordion,
Content as AccordionContent,
Item as AccordionItem,
Trigger as AccordionTrigger,
};

View file

@ -0,0 +1,49 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none",
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View file

@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View file

@ -0,0 +1,95 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
/**
* Hinweistext als Tooltip. Ersetzt `title` appweit und dient
* zugleich als Beschriftung, wenn der Knopf nur ein Icon zeigt —
* ohne das bliebe er für Screenreader namenlos.
*/
tooltip?: string;
};
</script>
<script lang="ts">
import { tooltip as tooltipAction } from "$lib/stores/app.svelte";
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
tooltip,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
aria-label={tooltip}
{...restProps}
use:tooltipAction={{ content: tooltip }}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
aria-label={tooltip}
{...restProps}
use:tooltipAction={{ content: tooltip }}
>
{@render children?.()}
</button>
{/if}

View file

@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn(
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-content"
class={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
</script>
<div
bind:this={ref}
data-slot="card"
data-size={size}
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View file

@ -0,0 +1,11 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {type} {...restProps} />

Some files were not shown because too many files have changed in this diff Show more