Compare commits
1 commit
master
...
abgebroche
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
279796111c |
216
CLAUDE.md
|
|
@ -1,46 +1,198 @@
|
||||||
# CLAUDE.md
|
# 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
|
## Project Overview
|
||||||
`stammtisch-hersbruck.de`: Wappen, ein Satz, ein Knopf nach
|
|
||||||
[kurbeler.de](https://kurbeler.de), dazu Impressum und Datenschutz.
|
|
||||||
|
|
||||||
Die Anwendung, die hier bis September 2026 lag, heißt jetzt **Kurbeler** und
|
**Kurbeler** (bis 2026-09-08 „stammtisch-hersbruck.de"; Domain und
|
||||||
wird in `~/Dokumente/Projekte/kurbeler` fortgeführt. **Neue Features gehören
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
Alle Frontend-Befehle laufen aus `frontend/`:
|
||||||
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.
|
|
||||||
|
|
||||||
## Aufbau
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
- `frontend/src/routes/+page.svelte` — der Wegweiser
|
# Start development server (runs on http://stammtisch-hersbruck.de.localhost:31337)
|
||||||
- `frontend/src/routes/imprint`, `privacy` — Rechtstexte, über
|
npm run dev
|
||||||
`LegalPage.svelte` gerahmt
|
|
||||||
- `frontend/src/routes/+layout.ts` — `prerender = true` für alles
|
|
||||||
- `frontend/static/stammtisch-hersbruck.svg` — das Wappen
|
|
||||||
|
|
||||||
`adapter-static`, kein Server im Betrieb. Gebaut wird mit Bun, ausgeliefert
|
# Build for production
|
||||||
mit nginx (`frontend/Dockerfile`).
|
npm run build
|
||||||
|
|
||||||
## Sprache
|
# Preview production build
|
||||||
|
npm run preview
|
||||||
|
|
||||||
Oberflächentexte und Kommentare deutsch, Bezeichner und Routenpfade englisch
|
# Type-check Svelte files
|
||||||
(`/imprint`, nicht `/impressum`) — dieselbe Regel wie in Kurbeler.
|
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,
|
# Generate TypeScript types from the schema migration in backend/pb_migrations
|
||||||
ohne dass es jemand ausdrücklich will: Darin liegen die Daten des alten
|
# (no running instance, no token, no network access needed)
|
||||||
Stammtisch-Deployments.
|
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/`
|
||||||
31
NAMING.md
|
|
@ -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.
|
|
||||||
89
README.md
|
|
@ -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
|
Zeitmessung und Verwaltung für Läufe des Stammtisch Hersbruck.
|
||||||
Link aus einer Gruppe von damals, soll nicht ins Leere laufen, sondern in
|
|
||||||
einem Blick sehen, wo es weitergeht.
|
|
||||||
|
|
||||||
## 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 |
|
| [`frontend/`](frontend/) | SvelteKit-5-Anwendung (Svelte 5 Runes, Tailwind 4) |
|
||||||
| `/imprint` | Impressum nach § 5 DDG |
|
| [`backend/`](backend/) | PocketBase-Instanz — Dockerfile, Schema-Migrationen |
|
||||||
| `/privacy` | Datenschutz — kurz, weil hier nichts passiert |
|
|
||||||
|
|
||||||
Kein Konto, keine Datenbank, kein Formular, keine Cookies, keine Schriften
|
## Schnellstart
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
Die App läuft dann auf http://stammtisch-hersbruck.de.localhost:31337
|
||||||
bun install
|
|
||||||
bun run dev # http://localhost:5173
|
|
||||||
bun run build # nach build/
|
|
||||||
bun run check # Typprüfung
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
Backend lokal (optional — der Default zeigt auf die produktive Instanz):
|
||||||
|
|
||||||
Eigenes Bild, wie bei Kurbeler: `frontend/Dockerfile` baut mit `oven/bun`
|
cd backend
|
||||||
(Version gepinnt) und liefert mit `nginx:alpine` aus;
|
cp .env.example .env # Werte eintragen
|
||||||
`frontend/docker-compose.yaml` ist das, was Coolify liest. Build Pack **Docker
|
docker compose up -d --build
|
||||||
Compose**, Base Directory `/frontend`, Compose-Datei `/docker-compose.yaml`.
|
|
||||||
|
|
||||||
> **Die Domain gehört dem Dienst, nicht der App.** Bei Build Pack Docker
|
Danach in `frontend/.env` `PUBLIC_PB_URL=http://127.0.0.1:8090` setzen.
|
||||||
> 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.
|
|
||||||
|
|
||||||
## Das Backend
|
Details stehen in [`backend/README.md`](backend/README.md).
|
||||||
|
|
||||||
`backend/` liegt noch im Repo und die PocketBase-Instanz hinter
|
## PocketBase
|
||||||
`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.
|
|
||||||
|
|
||||||
## 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
|
Welche Instanz das Frontend anspricht, entscheidet `PUBLIC_PB_URL` in
|
||||||
Umbenennung zu Kurbeler angefangen wurde, bevor sie ins eigene Repo zog. Er
|
`frontend/.env`. Das Schema ist als Migration in `backend/pb_migrations/`
|
||||||
wird nicht weiterentwickelt und ist nur da, damit die Geschichte nachlesbar
|
versioniert.
|
||||||
bleibt — unter anderem enthält er das Stammtisch-Wappen, das nur dort je
|
|
||||||
entstanden ist.
|
|
||||||
|
|
|
||||||
0
TODO.md
Normal file
BIN
UML_basic.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
|
|
@ -26,3 +26,18 @@ SUPERUSER_PASSWORD=bitte-aendern-min-8-zeichen
|
||||||
# Optional: überschreibt den in docker-compose.yaml gepinnten Default.
|
# Optional: überschreibt den in docker-compose.yaml gepinnten Default.
|
||||||
# Nur setzen, wenn bewusst eine andere PocketBase-Version gebaut werden soll.
|
# Nur setzen, wenn bewusst eine andere PocketBase-Version gebaut werden soll.
|
||||||
#PB_VERSION=0.39.11
|
#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)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# stammtisch-hersbruck.de — Backend (PocketBase)
|
# Kurbeler — Backend (PocketBase)
|
||||||
|
|
||||||
PocketBase-Instanz für Events, Läufe, Fahrer, Zeiten und Teams.
|
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
|
`pb_migrations/` kommt das Verzeichnis per `COPY` ins Image — eine Änderung
|
||||||
wirkt also erst nach `docker compose up -d --build`.
|
wirkt also erst nach `docker compose up -d --build`.
|
||||||
|
|
||||||
Bisher liegt dort ein Anliegen: die öffentlichen Routen der Einladungslinks.
|
|
||||||
|
|
||||||
| Datei | Zweck |
|
| 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.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
|
Warum überhaupt ein Hook? Wer über einen Link beitritt, ist noch nicht
|
||||||
angemeldet und muss drei Dinge auf einmal tun, die keine Collection-Regel
|
angemeldet und muss drei Dinge auf einmal tun, die keine Collection-Regel
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,15 @@ services:
|
||||||
# Superuser wird nur beim allerersten Start angelegt (siehe entrypoint.sh)
|
# Superuser wird nur beim allerersten Start angelegt (siehe entrypoint.sh)
|
||||||
SUPERUSER_EMAIL: ${SUPERUSER_EMAIL:-}
|
SUPERUSER_EMAIL: ${SUPERUSER_EMAIL:-}
|
||||||
SUPERUSER_PASSWORD: ${SUPERUSER_PASSWORD:-}
|
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:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8090/api/health"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8090/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|
|
||||||
74
backend/pb_hooks/directory.pb.js
Normal 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())
|
||||||
78
backend/pb_hooks/directory_lib.js
Normal 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,
|
||||||
|
}
|
||||||
112
backend/pb_hooks/mail_lib.js
Normal 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,
|
||||||
|
}
|
||||||
326
backend/pb_hooks/notifications.pb.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
221
backend/pb_hooks/notifications_lib.js
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -38,7 +38,10 @@ routerAdd('GET', '/api/team-invites/{token}', (e) => {
|
||||||
throw new NotFoundError('Diese Einladung gibt es nicht mehr.')
|
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 -------------------------------------------
|
// --- Konto anlegen und beitreten -------------------------------------------
|
||||||
|
|
@ -80,6 +83,17 @@ routerAdd('POST', '/api/team-invites/{token}/accept', (e) => {
|
||||||
throw new BadRequestError('Diese Einladung gilt nicht mehr.')
|
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".
|
// Ohne die Vorabprüfung meldete PocketBase nur „Value must be unique".
|
||||||
// Der Hinweis auf den zweiten Weg ist hier mehr wert als die
|
// Der Hinweis auf den zweiten Weg ist hier mehr wert als die
|
||||||
// Fehlermeldung.
|
// Fehlermeldung.
|
||||||
|
|
@ -143,3 +157,172 @@ routerAdd('POST', '/api/team-invites/{token}/join', (e) => {
|
||||||
|
|
||||||
return e.json(200, { ok: true, team: result.teamId })
|
return e.json(200, { ok: true, team: result.teamId })
|
||||||
}, $apis.requireAuth())
|
}, $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())
|
||||||
|
|
|
||||||
|
|
@ -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,
|
* 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.
|
* falls er dort noch keinen hat. Zählt die Einladung hoch.
|
||||||
|
|
@ -100,6 +129,29 @@ function joinTeam(txApp, invite, user, name) {
|
||||||
rider = null
|
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) {
|
if (!rider) {
|
||||||
rider = new Record(txApp.findCollectionByNameOrId('riders'))
|
rider = new Record(txApp.findCollectionByNameOrId('riders'))
|
||||||
rider.set('team', team.id)
|
rider.set('team', team.id)
|
||||||
|
|
@ -119,4 +171,4 @@ function joinTeam(txApp, invite, user, name) {
|
||||||
return { teamId: team.id, riderId: rider.id, joined: !isMember }
|
return { teamId: team.id, riderId: rider.id, joined: !isMember }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { findValidInvite, teamPayload, joinTeam }
|
module.exports = { findValidInvite, teamPayload, recipientPayload, joinTeam }
|
||||||
|
|
|
||||||
94
backend/pb_hooks/views/digest.html
Normal 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>
|
||||||
116
backend/pb_hooks/views/invite.html
Normal 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>
|
||||||
|
|
@ -2144,6 +2144,47 @@ migrate((app) => {
|
||||||
"system": false,
|
"system": false,
|
||||||
"type": "relation"
|
"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": "",
|
"help": "",
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
|
|
@ -2208,6 +2249,307 @@ migrate((app) => {
|
||||||
],
|
],
|
||||||
"system": false
|
"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
|
// importCollections erwartet das Array selbst, keinen JSON-String — die
|
||||||
|
|
|
||||||
38
backend/pb_migrations/1757340000_users_superadmin_delete.js
Normal 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)
|
||||||
|
})
|
||||||
83
backend/pb_migrations/1757600000_invites_personal.js
Normal 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)
|
||||||
|
})
|
||||||
158
backend/pb_migrations/1757700000_notifications.js
Normal 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
|
|
@ -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
|
|
@ -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)
|
||||||
BIN
design/logo/garnitur/icon-192.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
design/logo/garnitur/icon-512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
1
design/logo/garnitur/icon-dark.svg
Normal 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 |
1
design/logo/garnitur/icon-maskable.svg
Normal 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 |
1
design/logo/garnitur/icon.svg
Normal 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 |
1
design/logo/garnitur/logo-dark.svg
Normal 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 |
1
design/logo/garnitur/logo-mono.svg
Normal 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 |
1
design/logo/garnitur/logo.svg
Normal 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 |
1
design/logo/garnitur/mark.svg
Normal 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
|
|
@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
design/logo/kurbel-k/icon-192.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
design/logo/kurbel-k/icon-512.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
1
design/logo/kurbel-k/icon-dark.svg
Normal 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 |
1
design/logo/kurbel-k/icon-maskable.svg
Normal 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 |
1
design/logo/kurbel-k/icon.svg
Normal 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 |
1
design/logo/kurbel-k/logo-dark.svg
Normal 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 |
1
design/logo/kurbel-k/logo-mono.svg
Normal 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 |
1
design/logo/kurbel-k/logo.svg
Normal 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 |
1
design/logo/kurbel-k/mark.svg
Normal 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 |
BIN
design/logo/kurbelarm/icon-192.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
design/logo/kurbelarm/icon-512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
1
design/logo/kurbelarm/icon-dark.svg
Normal 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 |
1
design/logo/kurbelarm/icon-maskable.svg
Normal 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 |
1
design/logo/kurbelarm/icon.svg
Normal 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 |
1
design/logo/kurbelarm/logo-dark.svg
Normal 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 |
1
design/logo/kurbelarm/logo-mono.svg
Normal 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 |
1
design/logo/kurbelarm/logo.svg
Normal 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 |
1
design/logo/kurbelarm/mark.svg
Normal 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
|
|
@ -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
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
# Was nicht ins Bild gehört.
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
node_modules
|
|
||||||
build
|
|
||||||
.svelte-kit
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
*.log
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# stammtisch-hersbruck.de — Frontend
|
# Kurbeler — Frontend
|
||||||
|
|
||||||
SvelteKit-5-Anwendung (Svelte 5 Runes, Tailwind 4) für Zeitnahme und Verwaltung.
|
SvelteKit-5-Anwendung (Svelte 5 Runes, Tailwind 4) für Zeitnahme und Verwaltung.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "stammtisch-hersbruck.de",
|
"name": "kurbeler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.0-alpha",
|
"version": "2.0.0-beta",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19 || ^22.12 || >=24"
|
"node": "^20.19 || ^22.12 || >=24"
|
||||||
|
|
@ -12,21 +12,43 @@
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"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": {
|
"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/kit": "^2.43.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.18",
|
"@tailwindcss/typography": "^0.5.18",
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@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": "^5.39.5",
|
||||||
"svelte-check": "^4.3.2",
|
"svelte-check": "^4.3.2",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwind-variants": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.2",
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
frontend/scripts/schema-to-json.mjs
Normal 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)
|
||||||
|
|
@ -1,59 +1,92 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@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 *));
|
@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 {
|
:root {
|
||||||
--radius: 0.65rem;
|
--radius: 0.5rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.973 0.005 92);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.19 0.008 88);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(0.995 0.002 92);
|
||||||
--card-foreground: oklch(0.141 0.005 285.823);
|
--card-foreground: oklch(0.19 0.008 88);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(0.995 0.002 92);
|
||||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
--popover-foreground: oklch(0.19 0.008 88);
|
||||||
--primary: oklch(0.795 0.184 86.047);
|
--primary: oklch(0.795 0.184 86.047);
|
||||||
--primary-foreground: oklch(0.421 0.095 57.708);
|
--primary-foreground: oklch(0.421 0.095 57.708);
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
--secondary: oklch(0.951 0.007 92);
|
||||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
--secondary-foreground: oklch(0.24 0.008 88);
|
||||||
--muted: oklch(0.967 0.001 286.375);
|
--muted: oklch(0.951 0.007 92);
|
||||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
--muted-foreground: oklch(0.535 0.014 88);
|
||||||
--accent: oklch(0.967 0.001 286.375);
|
--accent: oklch(0.951 0.007 92);
|
||||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
--accent-foreground: oklch(0.24 0.008 88);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.92 0.004 286.32);
|
--border: oklch(0.898 0.011 90);
|
||||||
--input: oklch(0.92 0.004 286.32);
|
--input: oklch(0.898 0.011 90);
|
||||||
--ring: oklch(0.795 0.184 86.047);
|
--ring: oklch(0.795 0.184 86.047);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0.004 92);
|
||||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
--sidebar-foreground: oklch(0.19 0.008 88);
|
||||||
--sidebar-primary: oklch(0.795 0.184 86.047);
|
--sidebar-primary: oklch(0.795 0.184 86.047);
|
||||||
--sidebar-primary-foreground: oklch(0.421 0.095 57.708);
|
--sidebar-primary-foreground: oklch(0.421 0.095 57.708);
|
||||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
--sidebar-accent: oklch(0.951 0.007 92);
|
||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
--sidebar-accent-foreground: oklch(0.24 0.008 88);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-border: oklch(0.898 0.011 90);
|
||||||
--sidebar-ring: oklch(0.795 0.184 86.047);
|
--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 {
|
.dark {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.158 0.006 90);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.968 0.005 92);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.215 0.007 90);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.968 0.005 92);
|
||||||
--popover: oklch(0.21 0.006 285.885);
|
--popover: oklch(0.215 0.007 90);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.968 0.005 92);
|
||||||
--primary: oklch(0.852 0.199 91.936);
|
--primary: oklch(0.852 0.199 91.936);
|
||||||
--primary-foreground: oklch(0.421 0.095 57.708);
|
--primary-foreground: oklch(0.421 0.095 57.708);
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
--secondary: oklch(0.272 0.008 90);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.968 0.005 92);
|
||||||
--muted: oklch(0.274 0.006 286.033);
|
--muted: oklch(0.272 0.008 90);
|
||||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
--muted-foreground: oklch(0.702 0.014 90);
|
||||||
--accent: oklch(0.274 0.006 286.033);
|
--accent: oklch(0.272 0.008 90);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.968 0.005 92);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
|
@ -63,17 +96,33 @@
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.21 0.006 285.885);
|
--sidebar: oklch(0.215 0.007 90);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.968 0.005 92);
|
||||||
--sidebar-primary: oklch(0.852 0.199 91.936);
|
--sidebar-primary: oklch(0.852 0.199 91.936);
|
||||||
--sidebar-primary-foreground: oklch(0.421 0.095 57.708);
|
--sidebar-primary-foreground: oklch(0.421 0.095 57.708);
|
||||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
--sidebar-accent: oklch(0.272 0.008 90);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.968 0.005 92);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.554 0.135 66.442);
|
--sidebar-ring: oklch(0.554 0.135 66.442);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@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-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
|
|
@ -117,6 +166,30 @@
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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 {
|
@layer components {
|
||||||
/*
|
/*
|
||||||
* Startnummern wie am Trikot: schmal, fett, ohne Zierrat. Impact ist auf
|
* Startnummern. Vorher stand hier Impact mit vier Rueckfallschriften —
|
||||||
* Windows und macOS da, Haettenschweiler und Arial Narrow Bold springen
|
* eine Wette darauf, was auf dem Geraet installiert ist, die auf Android
|
||||||
* ein; am Ende steht eine beliebige serifenlose Schrift, dann sieht die
|
* und Linux verloren ging. Jetzt traegt sie Plex Mono, die ohnehin im
|
||||||
* Nummer eben normal aus statt falsch.
|
* 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-startnumber {
|
||||||
font-family: Impact, Haettenschweiler, 'Arial Narrow Bold', 'Franklin Gothic Bold', sans-serif;
|
font-family: var(--font-mono);
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
font-stretch: condensed;
|
font-variant-numeric: tabular-nums;
|
||||||
letter-spacing: 0.02em;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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" />
|
<meta name="theme-color" content="#eab308" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
1
frontend/src/lib/assets/favicon.svg
Normal 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
|
|
@ -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,
|
||||||
|
}
|
||||||
91
frontend/src/lib/components/Avatar.svelte
Normal 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}
|
||||||
120
frontend/src/lib/components/ClockSyncDialog.svelte
Normal 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>
|
||||||
36
frontend/src/lib/components/ConfirmDialog.svelte
Normal 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>
|
||||||
76
frontend/src/lib/components/CreateTeamDialog.svelte
Normal 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>
|
||||||
217
frontend/src/lib/components/ElevationProfile.svelte
Normal 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}
|
||||||
34
frontend/src/lib/components/FlagIcon.svelte
Normal 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>
|
||||||
31
frontend/src/lib/components/Icon.svelte
Normal 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>
|
||||||
54
frontend/src/lib/components/IconPicker.svelte
Normal 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>
|
||||||
|
|
@ -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>
|
|
||||||
36
frontend/src/lib/components/Logo.svelte
Normal 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}
|
||||||
21
frontend/src/lib/components/Mark.svelte
Normal 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>
|
||||||
243
frontend/src/lib/components/RunningTimesToast.svelte
Normal 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}
|
||||||
39
frontend/src/lib/components/Spinner.svelte
Normal 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>
|
||||||
101
frontend/src/lib/components/TeamMenu.svelte
Normal 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} />
|
||||||
281
frontend/src/lib/components/TimeDialog.svelte
Normal 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>
|
||||||
494
frontend/src/lib/components/TrailMap.svelte
Normal 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>
|
||||||
31
frontend/src/lib/components/Wordmark.svelte
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
|
@ -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>
|
||||||
16
frontend/src/lib/components/ui/accordion/accordion.svelte
Normal 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}
|
||||||
|
/>
|
||||||
16
frontend/src/lib/components/ui/accordion/index.ts
Normal 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,
|
||||||
|
};
|
||||||
49
frontend/src/lib/components/ui/badge/badge.svelte
Normal 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>
|
||||||
2
frontend/src/lib/components/ui/badge/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default as Badge } from "./badge.svelte";
|
||||||
|
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||||
95
frontend/src/lib/components/ui/button/button.svelte
Normal 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}
|
||||||
17
frontend/src/lib/components/ui/button/index.ts
Normal 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,
|
||||||
|
};
|
||||||
23
frontend/src/lib/components/ui/card/card-action.svelte
Normal 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>
|
||||||
20
frontend/src/lib/components/ui/card/card-content.svelte
Normal 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>
|
||||||
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal 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>
|
||||||
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal 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>
|
||||||
23
frontend/src/lib/components/ui/card/card-header.svelte
Normal 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>
|
||||||
20
frontend/src/lib/components/ui/card/card-title.svelte
Normal 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>
|
||||||
22
frontend/src/lib/components/ui/card/card.svelte
Normal 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>
|
||||||
25
frontend/src/lib/components/ui/card/index.ts
Normal 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,
|
||||||
|
};
|
||||||
11
frontend/src/lib/components/ui/dialog/dialog-close.svelte
Normal 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} />
|
||||||