wip: Der abgebrochene Kurbeler-Umbau, wie er hier liegengeblieben ist
Kein fertiger Stand, sondern ein Zwischenstand: Hier wurde im September 2026 angefangen, die App auf „Kurbeler" umzubenennen — Wortmarke, Icons, brand.ts, Benachrichtigungen, das Benutzerverzeichnis. Weitergegangen ist es dann im eigenen Repo `~/Dokumente/Projekte/kurbeler`, das die vollständige Historie dieses Repos mitgenommen hat. Committet wird das hier nicht, weil es gebraucht würde, sondern damit es nicht als Haufen unversionierter Dateien im Arbeitsverzeichnis verrottet. Wer in zwei Jahren nachsieht, findet so eine Geschichte statt eines Rätsels — und das Stammtisch-Wappen, das nur hier je existiert hat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LTw6xVYgjMy9AQtfs1Gdyu
39
CLAUDE.md
|
|
@ -4,7 +4,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
stammtisch-hersbruck.de 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.
|
**Kurbeler** (bis 2026-09-08 „stammtisch-hersbruck.de"; Domain und
|
||||||
|
Repository-Verzeichnis heißen weiterhin so) is a SvelteKit 5 application using Svelte 5's new runes syntax ($state, $props, etc.) with PocketBase as the backend. The app manages events, stages, riders, times and teams for a motorsport/cycling event organization.
|
||||||
|
|
||||||
The repository is a monorepo: the SvelteKit app lives in `frontend/`, the PocketBase instance in `backend/`.
|
The repository is a monorepo: the SvelteKit app lives in `frontend/`, the PocketBase instance in `backend/`.
|
||||||
|
|
||||||
|
|
@ -62,18 +63,30 @@ docker compose down
|
||||||
- **Type-safe client**: The PocketBase client is typed using auto-generated types in `frontend/src/lib/types.d.ts`
|
- **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,
|
- **Collections**: users, teams, team_invites, events, stages, riders, times,
|
||||||
trails, trail_versions, trail_flags, trail_markers, trail_comments,
|
trails, trail_versions, trail_flags, trail_markers, trail_comments,
|
||||||
event_participants, event_series
|
event_participants, event_series, notification_prefs, notifications
|
||||||
- **Regeln**: Jede Zugriffsregel, die `@request.auth.id` erwähnt, beginnt mit
|
- **Regeln**: Jede Zugriffsregel, die `@request.auth.id` erwähnt, beginnt mit
|
||||||
`@request.auth.id != "" && (…)`. Ohne diesen Vorspann ist eine *leere*
|
`@request.auth.id != "" && (…)`. Ohne diesen Vorspann ist eine *leere*
|
||||||
Relation gleich dem leeren `@request.auth.id` einer anonymen Anfrage — ein
|
Relation gleich dem leeren `@request.auth.id` einer anonymen Anfrage — ein
|
||||||
Team ohne Admins stünde damit offen im Netz. Siehe
|
Team ohne Admins stünde damit offen im Netz. Siehe
|
||||||
`backend/pb_migrations/1754501700_rules_require_login.js`.
|
`backend/pb_migrations/1754501700_rules_require_login.js`.
|
||||||
- **Hooks**: `backend/pb_hooks/` enthält serverseitiges JS für das, was keine
|
- **Hooks**: `backend/pb_hooks/` enthält serverseitiges JS für das, was keine
|
||||||
Collection-Regel abbilden kann — derzeit die öffentlichen Routen der
|
Collection-Regel abbilden kann — die öffentlichen Routen der Einladungslinks,
|
||||||
Einladungslinks. Jeder Route-Handler läuft in einer eigenen JS-Laufzeit;
|
den Uhrenabgleich, `GET /api/directory/users` (Benutzerverzeichnis des
|
||||||
gemeinsamer Code gehört in ein Modul und wird per
|
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
|
``require(`${__hooks}/…`)`` im Handler geholt. Details in
|
||||||
`backend/README.md`.
|
`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
|
- **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
|
- **File handling**: Use `getFileURL(record, file, options)` helper for PocketBase file URLs
|
||||||
|
|
||||||
|
|
@ -155,10 +168,20 @@ tippy.js, which the action initializes, updates and destroys.
|
||||||
- Der Menüpunkt **Team** (`/dashboard/team`) führt Team und Kader an einer
|
- Der Menüpunkt **Team** (`/dashboard/team`) führt Team und Kader an einer
|
||||||
Stelle: Kennzahlen, Teamdaten, Einladungslinks und die Fahrerliste samt der
|
Stelle: Kennzahlen, Teamdaten, Einladungslinks und die Fahrerliste samt der
|
||||||
Verwaltungsknöpfe der Teamleitung. Unter **Einstellungen** steht nur noch,
|
Verwaltungsknöpfe der Teamleitung. Unter **Einstellungen** steht nur noch,
|
||||||
was einen selbst betrifft (Profil, Flag-Typen).
|
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
|
- **Ein Fahrer, ein Name, eine Richtung**: Ein Konto entsteht immer am Fahrer
|
||||||
(`riders.createLogin`), es gibt kein Verknüpfen bestehender Konten und kein
|
— die Teamleitung lädt per Mail ein (`POST /api/team-invites/send`), das
|
||||||
Lösen. Angezeigt wird ausschließlich der Fahrername — der Kontoname wäre
|
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
|
eine zweite Wahrheit, die niemand geradeziehen könnte, weil
|
||||||
`users.updateRule` nur den Kontoinhaber selbst ändern lässt.
|
`users.updateRule` nur den Kontoinhaber selbst ändern lässt.
|
||||||
- Reusable components in `src/lib/components/`
|
- Reusable components in `src/lib/components/`
|
||||||
|
|
|
||||||
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.
|
|
||||||
19
README.md
|
|
@ -1,7 +1,24 @@
|
||||||
# stammtisch-hersbruck.de
|
# stammtisch-hersbruck.de — abgelöst
|
||||||
|
|
||||||
|
> **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.
|
||||||
|
|
||||||
|
|
||||||
Zeitmessung und Verwaltung für Läufe des Stammtisch Hersbruck.
|
Zeitmessung und Verwaltung für Läufe des Stammtisch Hersbruck.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
Das Repository enthält beide Teile der Anwendung:
|
Das Repository enthält beide Teile der Anwendung:
|
||||||
|
|
||||||
| Verzeichnis | Inhalt |
|
| Verzeichnis | Inhalt |
|
||||||
|
|
|
||||||
|
|
@ -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,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.
|
||||||
|
|
||||||
|
|
|
||||||
28
frontend/package-lock.json
generated
|
|
@ -1,13 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "stammtisch-hersbruck.de",
|
"name": "kurbeler",
|
||||||
"version": "2.0.0-alpha",
|
"version": "2.0.0-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "stammtisch-hersbruck.de",
|
"name": "kurbeler",
|
||||||
"version": "2.0.0-alpha",
|
"version": "2.0.0-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.4.13",
|
"dompurify": "^3.4.13",
|
||||||
"lucide-svelte": "^0.503.0",
|
"lucide-svelte": "^0.503.0",
|
||||||
|
|
@ -735,6 +737,24 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource-variable/inter": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/ibm-plex-mono": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@gar/promisify": {
|
"node_modules/@gar/promisify": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -41,6 +41,8 @@
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.4.13",
|
"dompurify": "^3.4.13",
|
||||||
"lucide-svelte": "^0.503.0",
|
"lucide-svelte": "^0.503.0",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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,
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,20 @@
|
||||||
size = 32,
|
size = 32,
|
||||||
class: className = '',
|
class: className = '',
|
||||||
}: {
|
}: {
|
||||||
user?: { id?: string; name?: string; email?: string; avatar?: string } | null
|
/**
|
||||||
|
* 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
|
size?: number
|
||||||
class?: string
|
class?: string
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
|
||||||
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>
|
||||||
|
|
@ -1,28 +1,36 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* Logo eines Teams. Ist eins hinterlegt, steht es allein; sonst bleibt das
|
* Logo eines Teams. Ist eins hinterlegt, steht es allein; sonst faellt es
|
||||||
* Wappen des Stammtischs als Rückfalloption — zwei übereinanderliegende
|
* auf das Zeichen der App zurueck.
|
||||||
* SVGs, weil das Radl über dem Schildl leicht übersteht.
|
*
|
||||||
|
* `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
|
* Groesse ueber eine Klasse steuerbar, damit dasselbe Logo im Header
|
||||||
* (klein) und auf der Login-Karte (gross) verwendet werden kann.
|
* (klein), auf der Login-Karte (gross) und als Wasserzeichen (riesig)
|
||||||
|
* verwendet werden kann.
|
||||||
*/
|
*/
|
||||||
import { getFileURL } from '$lib/stores/pocketbase.svelte'
|
import { getFileURL } from '$lib/stores/pocketbase.svelte'
|
||||||
import type { TeamsResponse } from '$lib/types'
|
import type { TeamsResponse } from '$lib/types'
|
||||||
|
import Mark from './Mark.svelte'
|
||||||
|
import Icon from './Icon.svelte'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
class: className = 'w-24 h-24',
|
class: className = 'w-24 h-24',
|
||||||
team = null,
|
team = null,
|
||||||
}: { class?: string; team?: TeamsResponse | null } = $props()
|
icon = false,
|
||||||
|
}: { class?: string; team?: TeamsResponse | null; icon?: boolean } = $props()
|
||||||
|
|
||||||
const url = $derived(team?.logo ? getFileURL(team, team.logo) : null)
|
const url = $derived(team?.logo ? getFileURL(team, team.logo) : null)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative {className}" id="logo">
|
{#if url}
|
||||||
{#if url}
|
<img alt="" class="{className} object-contain" src={url} />
|
||||||
<img alt="" class="absolute h-full w-full object-contain" src={url} />
|
{:else if icon}
|
||||||
{:else}
|
<Icon class={className} />
|
||||||
<img alt="" class="absolute h-full w-full object-contain" src="/schildl.svg">
|
{:else}
|
||||||
<img alt="" class="absolute h-full w-full scale-110 object-contain" src="/radl.svg">
|
<Mark class="{className} text-primary" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
|
||||||
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>
|
||||||
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>
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
export const buttonVariants = tv({
|
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-lg 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",
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm shadow-2xl ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot={dataSlot}
|
data-slot={dataSlot}
|
||||||
class={cn(
|
class={cn(
|
||||||
"dark:bg-input/30 border-input 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 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
"dark:bg-input/30 border-input 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 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-md border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
type="file"
|
type="file"
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot={dataSlot}
|
data-slot={dataSlot}
|
||||||
class={cn(
|
class={cn(
|
||||||
"dark:bg-input/30 border-input 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 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
"dark:bg-input/30 border-input 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 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-md border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{type}
|
{type}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
class={cn(
|
class={cn(
|
||||||
"border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 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 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
"border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 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 gap-1.5 rounded-md border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,12 @@
|
||||||
}: WithElementRef<HTMLThAttributes> = $props();
|
}: WithElementRef<HTMLThAttributes> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<th bind:this={ref} data-slot="table-head" class={cn("text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
|
<!--
|
||||||
|
Spaltenköpfe tragen die Kleinschrift des Hauses (`.label-mono`): schmal,
|
||||||
|
gesperrt, in Versalien. Ein Spaltenkopf benennt ein Feld, er füllt es
|
||||||
|
nicht — vorher stand er in derselben Schrift und Größe wie die Werte
|
||||||
|
darunter und stritt mit ihnen um die Aufmerksamkeit.
|
||||||
|
-->
|
||||||
|
<th bind:this={ref} data-slot="table-head" class={cn("label-mono h-9 px-2 text-left align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</th>
|
</th>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<thead
|
<thead
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="table-header"
|
data-slot="table-header"
|
||||||
class={cn("[&_tr]:border-b [&_tr]:hover:bg-transparent", className)}
|
class={cn("[&_tr]:border-b-2 [&_tr]:border-border [&_tr]:hover:bg-transparent", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|
|
||||||
182
frontend/src/lib/stores/notificationPrefs.svelte.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { api, auth } from './pocketbase.svelte'
|
||||||
|
import type { NotificationPrefsResponse } from '$lib/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was jemand per Mail erfahren will.
|
||||||
|
*
|
||||||
|
* Kein Context-Store wie die Collection-Stores: Diese Einstellungen gehören
|
||||||
|
* dem Konto und nicht dem aktiven Team, es gibt sie genau einmal je
|
||||||
|
* Anmeldung. Ein Modul-Singleton ist dafür die ehrlichere Form.
|
||||||
|
*
|
||||||
|
* Ein Datensatz entsteht erst beim ersten Speichern. Bis dahin gilt
|
||||||
|
* `DEFAULTS` — dieselbe Linie wie serverseitig in
|
||||||
|
* backend/pb_hooks/notifications_lib.js: An heißt „betrifft mich", aus heißt
|
||||||
|
* „ist viel". Beide Listen müssen zusammenpassen; stünde hier etwas anderes,
|
||||||
|
* zeigte die Oberfläche einen Schalter an, der nichts mit dem zu tun hat, was
|
||||||
|
* tatsächlich verschickt wird.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
| 'event_created'
|
||||||
|
| 'event_participation'
|
||||||
|
| 'event_changed'
|
||||||
|
| 'trail_comment'
|
||||||
|
| 'trail_marker'
|
||||||
|
| 'trail_status'
|
||||||
|
| 'team_member'
|
||||||
|
|
||||||
|
export type NotificationPrefs = Record<NotificationType, boolean> & { enabled: boolean }
|
||||||
|
|
||||||
|
export const DEFAULTS: NotificationPrefs = {
|
||||||
|
enabled: true,
|
||||||
|
|
||||||
|
event_created: true,
|
||||||
|
event_participation: true,
|
||||||
|
event_changed: true,
|
||||||
|
trail_comment: false,
|
||||||
|
trail_marker: false,
|
||||||
|
trail_status: true,
|
||||||
|
team_member: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reihenfolge, Beschriftung und Begründung — was die Oberfläche anzeigt. */
|
||||||
|
export const NOTIFICATION_GROUPS: {
|
||||||
|
title: string
|
||||||
|
items: { key: NotificationType; label: string; hint: string }[]
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
title: 'Events',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'event_created',
|
||||||
|
label: 'Neues Event im Team',
|
||||||
|
hint: 'Sobald jemand einen Termin anlegt.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'event_participation',
|
||||||
|
label: 'Du stehst auf der Teilnehmerliste',
|
||||||
|
hint: 'Wenn dich jemand zu einem Event einträgt.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'event_changed',
|
||||||
|
label: 'Termin oder Ort verschoben',
|
||||||
|
hint: 'Nur bei Datum und Ort — nicht bei jeder Textänderung.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Trails',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'trail_status',
|
||||||
|
label: 'Ein Trail wird gesperrt oder wieder frei',
|
||||||
|
hint: 'Danach richtet sich, ob die Ausfahrt am Sonntag stattfindet.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'trail_marker',
|
||||||
|
label: 'Neue Meldung an der Strecke',
|
||||||
|
hint: 'Umgestürzter Baum, kaputte Anlieger, jede neue Markierung.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'trail_comment',
|
||||||
|
label: 'Neuer Kommentar',
|
||||||
|
hint: 'Jeder Kommentar an jedem Trail des Teams. Kann viel werden.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Team',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'team_member',
|
||||||
|
label: 'Jemand ist neu im Kader',
|
||||||
|
hint: 'Bei jedem neu angelegten Fahrer.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const COLLECTION = 'notification_prefs'
|
||||||
|
|
||||||
|
export class NotificationPrefsStore {
|
||||||
|
/** Der Datensatz, sofern es schon einen gibt. */
|
||||||
|
record = $state<NotificationPrefsResponse | null>(null)
|
||||||
|
values = $state<NotificationPrefs>({ ...DEFAULTS })
|
||||||
|
loading = $state(false)
|
||||||
|
saving = $state(false)
|
||||||
|
error = $state<string | null>(null)
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const uid = auth.user?.id
|
||||||
|
if (!uid) return
|
||||||
|
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
const list = await api.collection(COLLECTION).getFullList({
|
||||||
|
filter: `user = "${uid}"`,
|
||||||
|
requestKey: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const found = (list[0] as NotificationPrefsResponse) ?? null
|
||||||
|
this.record = found
|
||||||
|
|
||||||
|
// Ohne Datensatz bleiben die Voreinstellungen stehen. Sie sind
|
||||||
|
// keine Behelfsanzeige, sondern genau das, was der Server tut.
|
||||||
|
if (found) {
|
||||||
|
const next = { ...DEFAULTS }
|
||||||
|
for (const key of Object.keys(DEFAULTS) as (keyof NotificationPrefs)[]) {
|
||||||
|
next[key] = !!found[key as keyof NotificationPrefsResponse]
|
||||||
|
}
|
||||||
|
this.values = next
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e.message ?? 'Die Einstellungen konnten nicht geladen werden.'
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einen Schalter umlegen und sofort speichern.
|
||||||
|
*
|
||||||
|
* Kein Speichern-Knopf: Es sind Schalter, sie gehören nur dem Kontoinhaber
|
||||||
|
* und jeder für sich ergibt sofort Sinn — genau wie Name und Bild im
|
||||||
|
* Profil. Ein Knopf, der fast immer nichts zu tun hat, ist eine Frage an
|
||||||
|
* jemanden, der längst geantwortet hat.
|
||||||
|
*/
|
||||||
|
async set(key: keyof NotificationPrefs, value: boolean) {
|
||||||
|
const uid = auth.user?.id
|
||||||
|
if (!uid || this.saving) return
|
||||||
|
|
||||||
|
const previous = this.values[key]
|
||||||
|
this.values = { ...this.values, [key]: value }
|
||||||
|
|
||||||
|
this.saving = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
if (this.record) {
|
||||||
|
this.record = (await api
|
||||||
|
.collection(COLLECTION)
|
||||||
|
.update(this.record.id, { [key]: value })) as NotificationPrefsResponse
|
||||||
|
} else {
|
||||||
|
// Der erste Griff an die Schalter legt den Datensatz an — mit
|
||||||
|
// allen Werten, nicht nur dem geänderten. Sonst stünden die
|
||||||
|
// übrigen auf `false` statt auf ihrer Voreinstellung, und
|
||||||
|
// wer einen Schalter ausmacht, hätte nebenbei alle anderen
|
||||||
|
// mit ausgemacht.
|
||||||
|
this.record = (await api.collection(COLLECTION).create({
|
||||||
|
...this.values,
|
||||||
|
user: uid,
|
||||||
|
})) as NotificationPrefsResponse
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.values = { ...this.values, [key]: previous }
|
||||||
|
this.error = e.message ?? 'Das konnte nicht gespeichert werden.'
|
||||||
|
} finally {
|
||||||
|
this.saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationPrefs = new NotificationPrefsStore()
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { getContext, setContext } from 'svelte'
|
import { getContext, setContext } from 'svelte'
|
||||||
import { api } from './pocketbase.svelte'
|
import { api } from './pocketbase.svelte'
|
||||||
import { app } from './app.svelte'
|
|
||||||
import { getTeamContext } from './teams.svelte'
|
import { getTeamContext } from './teams.svelte'
|
||||||
import type { RidersResponse, UsersResponse } from '$lib/types'
|
import type { RidersResponse, UsersResponse } from '$lib/types'
|
||||||
|
|
||||||
|
|
@ -92,62 +91,22 @@ export class RiderStore {
|
||||||
return await api.collection('riders').update(id, data)
|
return await api.collection('riders').update(id, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Ein „Fahrer löschen" gibt es nicht mehr.
|
||||||
* Fahrer löschen — und mit ihm die Mitgliedschaft seines Kontos.
|
//
|
||||||
*
|
// Im Kader stand bis hierher ein Papierkorb neben „Aus dem Team
|
||||||
* Ohne den zweiten Schritt riss jedes Löschen ein Loch: Der Fahrer war
|
// entfernen". Was er wirklich tat, war aus zwei Icons nicht abzulesen:
|
||||||
* fort, sein Konto blieb in `teams.users` und stand danach als Zeile ohne
|
// Er löschte den Fahrer samt Teilnahmen, nahm nebenbei das Konto aus dem
|
||||||
* Fahrer im Kader. Aus so einer Zeile führte kein Weg zurück — ein Konto
|
// Team — und ließ die Zeiten stehen, obwohl der Rückfragetext das
|
||||||
* nachträglich an einen Fahrer zu hängen gibt es bewusst nicht, weil
|
// Gegenteil versprach. `times.rider` hat cascadeDelete: false; die
|
||||||
* dieselbe Person sonst zweimal in der Liste stehen könnte. Der Eintrag
|
// Zeit-Datensätze überlebten mit leerem Fahrer und standen danach
|
||||||
* war damit unreparierbar.
|
// namenlos in der Stage-Rangliste, die nach Stage filtert und nicht nach
|
||||||
*
|
// Fahrer.
|
||||||
* Das Konto selbst bleibt bestehen: Löschen darf es allein sein Inhaber
|
//
|
||||||
* (`users.deleteRule = id = @request.auth.id`), und über einen neuen
|
// Geblieben ist der eine Eingriff, der niemandem etwas wegnimmt:
|
||||||
* Einladungslink kommt es zurück ins Team.
|
// `teams.removeMember` entzieht den Zugang zum Team. Fahrer, Teilnahmen
|
||||||
*
|
// und Zeiten bleiben — sie sind die Geschichte des Teams und nicht die
|
||||||
* Der Owner ist ausgenommen. Ihn aus `teams.users` zu nehmen, nähme ihm
|
// des Logins. Konten löscht allein ihr Inhaber oder der Superadmin im
|
||||||
* die Sicht auf sein eigenes Team — die listRule der meisten Collections
|
// Benutzerverzeichnis unter Einstellungen.
|
||||||
* hängt an `team.users`, nicht an `team.owner`.
|
|
||||||
*/
|
|
||||||
remove(record: RidersResponse) {
|
|
||||||
const team = this.teams.records.find((t) => t.id === record.team)
|
|
||||||
const userId = record.user || null
|
|
||||||
const dropsMembership = !!userId && !!team && userId !== team.owner
|
|
||||||
|
|
||||||
const text = dropsMembership
|
|
||||||
? `Möchten Sie „${riderName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht. ` +
|
|
||||||
`Das zugehörige Konto verliert seine Mitgliedschaft im Team — gelöscht wird es nicht, es kann über einen Einladungslink zurückkommen.`
|
|
||||||
: `Möchten Sie „${riderName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.`
|
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
app.confirm.request({
|
|
||||||
title: 'Fahrer löschen?',
|
|
||||||
text,
|
|
||||||
yes: async () => {
|
|
||||||
try {
|
|
||||||
// Erst die Mitgliedschaft, dann der Fahrer. Andersherum
|
|
||||||
// stünde bei einem Fehler im zweiten Schritt genau das
|
|
||||||
// verwaiste Konto da, das hier verhindert werden soll.
|
|
||||||
if (dropsMembership) {
|
|
||||||
await this.teams.removeMember(team!.id, userId!)
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.collection('riders').delete(record.id)
|
|
||||||
app.confirm.close()
|
|
||||||
resolve()
|
|
||||||
} catch (e) {
|
|
||||||
app.confirm.close()
|
|
||||||
reject(e)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
no: () => {
|
|
||||||
app.confirm.close()
|
|
||||||
resolve()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getById(id: string) {
|
getById(id: string) {
|
||||||
return this.records.find((r) => r.id === id)
|
return this.records.find((r) => r.id === id)
|
||||||
|
|
@ -158,33 +117,19 @@ export class RiderStore {
|
||||||
return this.scoped.filter((r) => r.user === userId)
|
return this.scoped.filter((r) => r.user === userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Ein Konto legt der Kader nicht mehr an.
|
||||||
* Konto an den Fahrer hängen. Nur intern und nur in diese Richtung: Ein
|
//
|
||||||
* Konto entsteht am Fahrer, ein Lösen der Verbindung gibt es nicht — sonst
|
// Hier standen `linkUser` und `createLogin`: Die Teamleitung dachte sich
|
||||||
* stünde dieselbe Person zweimal in der Liste, einmal mit und einmal ohne
|
// eine Adresse und ein Passwort aus, das Konto entstand sofort — und sie
|
||||||
* Login.
|
// kannte danach das Passwort eines anderen Menschen und musste es
|
||||||
*/
|
// irgendwie zu ihm bringen.
|
||||||
private async linkUser(riderId: string, userId: string) {
|
//
|
||||||
return await this.edit(riderId, { user: userId } as Partial<RidersResponse>)
|
// Stattdessen geht eine Einladung raus (`teamInvites.send`). Verknüpft
|
||||||
}
|
// wird der Fahrer erst, wenn der Eingeladene den Link öffnet und sein
|
||||||
|
// Passwort selbst setzt; das erledigt `joinTeam` in
|
||||||
/**
|
// backend/pb_hooks/team_invites_lib.js über das Feld `rider` der
|
||||||
* Legt dem Fahrer ein Login an und verknüpft es. Das Konto entsteht im
|
// Einladung. Die Richtung bleibt dieselbe wie zuvor — ein Konto entsteht
|
||||||
* Team-Store — es ist zuerst ein Teammitglied und erst dann dieser Fahrer.
|
// am Fahrer, gelöst wird die Verbindung nie.
|
||||||
*/
|
|
||||||
async createLogin(
|
|
||||||
rider: RidersResponse,
|
|
||||||
data: { email: string; password: string; name?: string },
|
|
||||||
): Promise<UsersResponse> {
|
|
||||||
const user = await this.teams.createMember({
|
|
||||||
...data,
|
|
||||||
name: data.name?.trim() || riderName(rider),
|
|
||||||
})
|
|
||||||
|
|
||||||
await this.linkUser(rider.id, user.id)
|
|
||||||
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
|
|
||||||
get filtered() {
|
get filtered() {
|
||||||
const q = this.searchQuery.toLowerCase().trim()
|
const q = this.searchQuery.toLowerCase().trim()
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,29 @@ export class TeamInviteStore {
|
||||||
return invite as TeamInvitesResponse
|
return invite as TeamInvitesResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persönliche Einladung an einen Fahrer verschicken.
|
||||||
|
*
|
||||||
|
* Nicht über die Collection, sondern über die Route: Anlegen und
|
||||||
|
* Verschicken gehören zusammen. Eine Einladung, die niemanden erreicht,
|
||||||
|
* ist keine — sie stünde nur als Datensatz herum, während die
|
||||||
|
* Teamleitung auf jemanden wartet, der nie eine Mail bekommen hat. Der
|
||||||
|
* Hook macht beides oder nichts.
|
||||||
|
*
|
||||||
|
* Das ist der Ersatz für „Konto anlegen mit Passwort": Das Passwort
|
||||||
|
* entsteht erst beim Empfänger, niemand sonst kennt es je.
|
||||||
|
*/
|
||||||
|
async send({ rider, email, days = 14 }: { rider: string; email: string; days?: number }) {
|
||||||
|
const res = await api.send('/api/team-invites/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { rider, email, days },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Die Einladung selbst kommt über das Realtime-Abo herein; hier steht
|
||||||
|
// nur, was der Aufrufer für seine Rückmeldung braucht.
|
||||||
|
return res as { ok: boolean; email: string; expires: string }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Link zurückziehen. Ohne Rückfrage: Ein Einladungslink ist nichts, was
|
* Link zurückziehen. Ohne Rückfrage: Ein Einladungslink ist nichts, was
|
||||||
* verloren gehen kann — wer einen braucht, erzeugt den nächsten.
|
* verloren gehen kann — wer einen braucht, erzeugt den nächsten.
|
||||||
|
|
@ -161,11 +184,31 @@ async function send(path: string, body?: unknown) {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wem gilt die Einladung? Ein Einladungslink gilt jedem, der ihn hat —
|
||||||
|
* `personal` ist dann false und die Felder sind leer. Eine persönliche
|
||||||
|
* Einladung nennt Adresse und Namen aus dem Kader; die Seite füllt beides vor.
|
||||||
|
*/
|
||||||
|
export type InviteRecipient = {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
personal: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export const publicInvite = {
|
export const publicInvite = {
|
||||||
/** Zu welchem Team gehört der Link? Wirft, wenn er nicht (mehr) gilt. */
|
/** Zu welchem Team gehört der Link? Wirft, wenn er nicht (mehr) gilt. */
|
||||||
async load(token: string): Promise<InviteTeam> {
|
async load(token: string): Promise<{ team: InviteTeam; recipient: InviteRecipient }> {
|
||||||
const data = await send(`/api/team-invites/${encodeURIComponent(token)}`)
|
const data = await send(`/api/team-invites/${encodeURIComponent(token)}`)
|
||||||
return data.team as InviteTeam
|
return {
|
||||||
|
team: data.team as InviteTeam,
|
||||||
|
// Ältere Instanzen kennen das Feld noch nicht — dann ist es ein
|
||||||
|
// gewöhnlicher Einladungslink.
|
||||||
|
recipient: (data.recipient ?? {
|
||||||
|
email: '',
|
||||||
|
name: '',
|
||||||
|
personal: false,
|
||||||
|
}) as InviteRecipient,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Konto anlegen und beitreten. */
|
/** Konto anlegen und beitreten. */
|
||||||
|
|
|
||||||
|
|
@ -219,32 +219,11 @@ export class TeamStore {
|
||||||
return u.name || u.email || id.slice(0, 6) + '…'
|
return u.name || u.email || id.slice(0, 6) + '…'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// `createMember` ist entfallen. Es legte ein Konto mitsamt Passwort an —
|
||||||
* Legt ein Login an und nimmt es ins Team auf. Zwei Schritte, weil die
|
// gebraucht wurde es allein von `riders.createLogin`, und das gibt es
|
||||||
* REST-API keine Transaktion kennt: Scheitert die Aufnahme, existiert das
|
// nicht mehr. Konten entstehen jetzt ausschließlich dort, wo auch das
|
||||||
* Konto bereits — der Aufrufer meldet den Fehler, statt still
|
// Passwort entsteht: beim Empfänger einer Einladung, in den Routen aus
|
||||||
* weiterzumachen.
|
// backend/pb_hooks/team_invites.pb.js.
|
||||||
*
|
|
||||||
* Erlaubt ist das seit 1754500500_riders_user_and_admin_rules.js jedem
|
|
||||||
* eingeloggten Nutzer (users.createRule); eine öffentliche Registrierung
|
|
||||||
* gibt es weiterhin nicht.
|
|
||||||
*/
|
|
||||||
async createMember(data: { email: string; password: string; name?: string }, teamId = this.activeId) {
|
|
||||||
if (!teamId) throw new Error('Kein aktives Team')
|
|
||||||
|
|
||||||
const user = (await api.collection('users').create({
|
|
||||||
email: data.email.trim(),
|
|
||||||
emailVisibility: false,
|
|
||||||
password: data.password,
|
|
||||||
passwordConfirm: data.password,
|
|
||||||
name: data.name?.trim() || data.email.trim(),
|
|
||||||
})) as UsersResponse
|
|
||||||
|
|
||||||
this.users = { ...this.users, [user.id]: user }
|
|
||||||
await this.addMember(teamId, user.id)
|
|
||||||
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.unsubscribe) {
|
if (this.unsubscribe) {
|
||||||
|
|
|
||||||
41
frontend/src/lib/types.d.ts
vendored
|
|
@ -9,6 +9,8 @@ export enum Collections {
|
||||||
EventParticipants = "event_participants",
|
EventParticipants = "event_participants",
|
||||||
EventSeries = "event_series",
|
EventSeries = "event_series",
|
||||||
Events = "events",
|
Events = "events",
|
||||||
|
NotificationPrefs = "notification_prefs",
|
||||||
|
Notifications = "notifications",
|
||||||
Riders = "riders",
|
Riders = "riders",
|
||||||
Stages = "stages",
|
Stages = "stages",
|
||||||
TeamInvites = "team_invites",
|
TeamInvites = "team_invites",
|
||||||
|
|
@ -114,6 +116,34 @@ export type EventsRecord = {
|
||||||
updated?: IsoDateString
|
updated?: IsoDateString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NotificationPrefsRecord = {
|
||||||
|
created?: IsoDateString
|
||||||
|
enabled?: boolean
|
||||||
|
event_changed?: boolean
|
||||||
|
event_created?: boolean
|
||||||
|
event_participation?: boolean
|
||||||
|
id: string
|
||||||
|
team_member?: boolean
|
||||||
|
trail_comment?: boolean
|
||||||
|
trail_marker?: boolean
|
||||||
|
trail_status?: boolean
|
||||||
|
updated?: IsoDateString
|
||||||
|
user: RecordIdString
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationsRecord = {
|
||||||
|
body?: string
|
||||||
|
created?: IsoDateString
|
||||||
|
id: string
|
||||||
|
link?: string
|
||||||
|
sent?: IsoDateString
|
||||||
|
team?: RecordIdString
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
updated?: IsoDateString
|
||||||
|
user: RecordIdString
|
||||||
|
}
|
||||||
|
|
||||||
export type RidersRecord = {
|
export type RidersRecord = {
|
||||||
avatar?: string
|
avatar?: string
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
|
|
@ -136,9 +166,12 @@ export type StagesRecord = {
|
||||||
export type TeamInvitesRecord = {
|
export type TeamInvitesRecord = {
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
created_by?: RecordIdString
|
created_by?: RecordIdString
|
||||||
|
email?: string
|
||||||
expires?: IsoDateString
|
expires?: IsoDateString
|
||||||
id: string
|
id: string
|
||||||
max_uses?: number
|
max_uses?: number
|
||||||
|
rider?: RecordIdString
|
||||||
|
sent?: IsoDateString
|
||||||
team: RecordIdString
|
team: RecordIdString
|
||||||
token: string
|
token: string
|
||||||
updated?: IsoDateString
|
updated?: IsoDateString
|
||||||
|
|
@ -277,6 +310,8 @@ export type UsersRecord = {
|
||||||
export type EventParticipantsResponse<Texpand = unknown> = Required<EventParticipantsRecord> & BaseSystemFields<Texpand>
|
export type EventParticipantsResponse<Texpand = unknown> = Required<EventParticipantsRecord> & BaseSystemFields<Texpand>
|
||||||
export type EventSeriesResponse<Texpand = unknown> = Required<EventSeriesRecord> & BaseSystemFields<Texpand>
|
export type EventSeriesResponse<Texpand = unknown> = Required<EventSeriesRecord> & BaseSystemFields<Texpand>
|
||||||
export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand>
|
export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand>
|
||||||
|
export type NotificationPrefsResponse<Texpand = unknown> = Required<NotificationPrefsRecord> & BaseSystemFields<Texpand>
|
||||||
|
export type NotificationsResponse<Texpand = unknown> = Required<NotificationsRecord> & BaseSystemFields<Texpand>
|
||||||
export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & BaseSystemFields<Texpand>
|
export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & BaseSystemFields<Texpand>
|
||||||
export type StagesResponse<Texpand = unknown> = Required<StagesRecord> & BaseSystemFields<Texpand>
|
export type StagesResponse<Texpand = unknown> = Required<StagesRecord> & BaseSystemFields<Texpand>
|
||||||
export type TeamInvitesResponse<Texpand = unknown> = Required<TeamInvitesRecord> & BaseSystemFields<Texpand>
|
export type TeamInvitesResponse<Texpand = unknown> = Required<TeamInvitesRecord> & BaseSystemFields<Texpand>
|
||||||
|
|
@ -295,6 +330,8 @@ export type CollectionRecords = {
|
||||||
event_participants: EventParticipantsRecord
|
event_participants: EventParticipantsRecord
|
||||||
event_series: EventSeriesRecord
|
event_series: EventSeriesRecord
|
||||||
events: EventsRecord
|
events: EventsRecord
|
||||||
|
notification_prefs: NotificationPrefsRecord
|
||||||
|
notifications: NotificationsRecord
|
||||||
riders: RidersRecord
|
riders: RidersRecord
|
||||||
stages: StagesRecord
|
stages: StagesRecord
|
||||||
team_invites: TeamInvitesRecord
|
team_invites: TeamInvitesRecord
|
||||||
|
|
@ -312,6 +349,8 @@ export type CollectionResponses = {
|
||||||
event_participants: EventParticipantsResponse
|
event_participants: EventParticipantsResponse
|
||||||
event_series: EventSeriesResponse
|
event_series: EventSeriesResponse
|
||||||
events: EventsResponse
|
events: EventsResponse
|
||||||
|
notification_prefs: NotificationPrefsResponse
|
||||||
|
notifications: NotificationsResponse
|
||||||
riders: RidersResponse
|
riders: RidersResponse
|
||||||
stages: StagesResponse
|
stages: StagesResponse
|
||||||
team_invites: TeamInvitesResponse
|
team_invites: TeamInvitesResponse
|
||||||
|
|
@ -332,6 +371,8 @@ export type TypedPocketBase = PocketBase & {
|
||||||
collection(idOrName: 'event_participants'): RecordService<EventParticipantsResponse>
|
collection(idOrName: 'event_participants'): RecordService<EventParticipantsResponse>
|
||||||
collection(idOrName: 'event_series'): RecordService<EventSeriesResponse>
|
collection(idOrName: 'event_series'): RecordService<EventSeriesResponse>
|
||||||
collection(idOrName: 'events'): RecordService<EventsResponse>
|
collection(idOrName: 'events'): RecordService<EventsResponse>
|
||||||
|
collection(idOrName: 'notification_prefs'): RecordService<NotificationPrefsResponse>
|
||||||
|
collection(idOrName: 'notifications'): RecordService<NotificationsResponse>
|
||||||
collection(idOrName: 'riders'): RecordService<RidersResponse>
|
collection(idOrName: 'riders'): RecordService<RidersResponse>
|
||||||
collection(idOrName: 'stages'): RecordService<StagesResponse>
|
collection(idOrName: 'stages'): RecordService<StagesResponse>
|
||||||
collection(idOrName: 'team_invites'): RecordService<TeamInvitesResponse>
|
collection(idOrName: 'team_invites'): RecordService<TeamInvitesResponse>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css'
|
import '../app.css'
|
||||||
import favicon from '$lib/assets/favicon.svg'
|
|
||||||
import {ModeWatcher} from "mode-watcher"
|
import {ModeWatcher} from "mode-watcher"
|
||||||
import {api, auth} from '$lib/stores/pocketbase.svelte'
|
import {api, auth} from '$lib/stores/pocketbase.svelte'
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
|
||||||
|
|
@ -24,7 +23,8 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link href={favicon} rel="icon"/>
|
<link href="/kurbeler-icon.svg" rel="icon" type="image/svg+xml"/>
|
||||||
|
<link href="/kurbeler-icon-192.png" rel="apple-touch-icon"/>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<ModeWatcher/>
|
<ModeWatcher/>
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@
|
||||||
onmouseenter={() => (dashHover = true)}
|
onmouseenter={() => (dashHover = true)}
|
||||||
onmouseleave={() => (dashHover = false)}
|
onmouseleave={() => (dashHover = false)}
|
||||||
>
|
>
|
||||||
<Logo team={teams.active} class="size-[4.5rem] -my-3" />
|
<Logo team={teams.active} icon class="size-[4.5rem] -my-3" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Dashboard – Zeitnahme</title>
|
<title>Dashboard · Kurbeler</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
|
|
@ -46,39 +46,33 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Drei Kennzahlen, ein Bauplan. Vorher stand jede Kachel dreimal
|
||||||
|
ausgeschrieben da; wer eine anfasste, musste an die anderen beiden
|
||||||
|
denken. Als Snippet gibt es nur noch eine Stelle, an der sich Abstand,
|
||||||
|
Schriftgrad und Reihenfolge entscheiden.
|
||||||
|
|
||||||
|
Die Beschriftung steht in der Kleinschrift des Hauses, der Wert in
|
||||||
|
Mono mit tabellarischen Ziffern — eine 111 ist dadurch genau so breit
|
||||||
|
wie eine 999 und die Kacheln zucken nicht, wenn eine Zeit dazukommt.
|
||||||
|
-->
|
||||||
|
{#snippet stat(label: string, value: number, Icon: typeof Calendar, hint: string)}
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle class="label-mono">{label}</CardTitle>
|
||||||
|
<Icon class="size-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="numeric text-3xl font-semibold">{value}</div>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">{hint}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card>
|
{@render stat('Events', stats.totalEvents, Calendar, `${stats.activeEvents} heute`)}
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
{@render stat('Fahrer', stats.totalRiders, Users, 'registriert')}
|
||||||
<CardTitle class="text-sm font-medium text-muted-foreground">Events</CardTitle>
|
{@render stat('Laufende Zeiten', stats.running, Clock, `${stats.finished} abgeschlossen`)}
|
||||||
<Calendar class="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div class="text-3xl font-bold">{stats.totalEvents}</div>
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">{stats.activeEvents} heute</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle class="text-sm font-medium text-muted-foreground">Fahrer</CardTitle>
|
|
||||||
<Users class="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div class="text-3xl font-bold">{stats.totalRiders}</div>
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">registriert</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle class="text-sm font-medium text-muted-foreground">Laufende Zeiten</CardTitle>
|
|
||||||
<Clock class="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div class="text-3xl font-bold">{stats.running}</div>
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">{stats.finished} abgeschlossen</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,7 @@
|
||||||
])
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Events – Zeitnahme</title></svelte:head>
|
<svelte:head><title>Events · Kurbeler</title></svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,23 @@
|
||||||
const eventParticipants = $derived(event ? participants.byEvent(event.id) : [])
|
const eventParticipants = $derived(event ? participants.byEvent(event.id) : [])
|
||||||
const eventStages = $derived(event ? stages.getByEvent(event.id) : [])
|
const eventStages = $derived(event ? stages.getByEvent(event.id) : [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele Stages *dieses* Events hat ein Fahrer gewertet hinter sich?
|
||||||
|
*
|
||||||
|
* Gezaehlt werden Stages, nicht Zeiten: mehrere Versuche auf derselben
|
||||||
|
* Stage sind ein erledigter Lauf, kein zweiter.
|
||||||
|
*/
|
||||||
|
function stagesDone(riderId: string): number {
|
||||||
|
const ids = new Set(eventStages.map((s) => s.id))
|
||||||
|
const done = new Set(
|
||||||
|
times
|
||||||
|
.getByRider(riderId)
|
||||||
|
.filter((t) => t.status === 'finished' && ids.has(t.stage))
|
||||||
|
.map((t) => t.stage),
|
||||||
|
)
|
||||||
|
return done.size
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Teilnehmer verwaltet, wer das Event angelegt hat, und die Teamleitung —
|
* Teilnehmer verwaltet, wer das Event angelegt hat, und die Teamleitung —
|
||||||
* dieselbe Regel wie in der Collection.
|
* dieselbe Regel wie in der Collection.
|
||||||
|
|
@ -254,7 +271,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{event?.name ?? 'Event'} – Zeitnahme</title></svelte:head>
|
<svelte:head><title>{event?.name ?? 'Event'} · Kurbeler</title></svelte:head>
|
||||||
|
|
||||||
{#if !event}
|
{#if !event}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -376,7 +393,6 @@
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each eventParticipants as p (p.id)}
|
{#each eventParticipants as p (p.id)}
|
||||||
{@const rider = riders.getById(p.rider)}
|
{@const rider = riders.getById(p.rider)}
|
||||||
{@const done = times.getByRider(p.rider).filter((t) => t.status === 'finished').length}
|
|
||||||
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(p.rider))}>
|
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(p.rider))}>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge variant="outline">{p.number || '—'}</Badge>
|
<Badge variant="outline">{p.number || '—'}</Badge>
|
||||||
|
|
@ -404,7 +420,11 @@
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{#if event.timing}
|
{#if event.timing}
|
||||||
<Table.Cell class="text-muted-foreground text-sm">
|
<Table.Cell class="text-muted-foreground text-sm">
|
||||||
{done} von {eventStages.length} Stages
|
{#if eventStages.length === 0}
|
||||||
|
—
|
||||||
|
{:else}
|
||||||
|
{stagesDone(p.rider)} von {eventStages.length} Stages
|
||||||
|
{/if}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{/if}
|
{/if}
|
||||||
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
|
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{rider ? riderName(rider) : 'Fahrer'} – {event?.name ?? 'Event'}</title></svelte:head>
|
<svelte:head><title>{rider ? riderName(rider) : 'Fahrer'} – {event?.name ?? 'Event'} · Kurbeler</title></svelte:head>
|
||||||
|
|
||||||
{#if !valid}
|
{#if !valid}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{stage?.description ?? 'Stage'} – {event?.name ?? 'Event'}</title></svelte:head>
|
<svelte:head><title>{stage?.description ?? 'Stage'} – {event?.name ?? 'Event'} · Kurbeler</title></svelte:head>
|
||||||
|
|
||||||
{#if !valid}
|
{#if !valid}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
280
frontend/src/routes/dashboard/riders/[riderId]/+page.svelte
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Das Profil eines Fahrers — über alle Events hinweg.
|
||||||
|
*
|
||||||
|
* Die Fahrerseite unter `/dashboard/events/[id]/riders/[riderId]` zeigt
|
||||||
|
* einen Fahrer *in einem Rennen*. Wer im Kader auf einen Namen klickt,
|
||||||
|
* meint aber die Person und nicht einen ihrer Termine: Hier stehen die
|
||||||
|
* Kennzahlen des Fahrers und darunter jede Teilnahme, von der aus es in
|
||||||
|
* das jeweilige Event weitergeht.
|
||||||
|
*/
|
||||||
|
import { page } from '$app/state'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||||
|
import { getEventContext } from '$lib/stores/events.svelte'
|
||||||
|
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||||
|
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
|
import {
|
||||||
|
getEventParticipantContext,
|
||||||
|
PARTICIPANT_STATUS_LABEL,
|
||||||
|
} from '$lib/stores/eventParticipants.svelte'
|
||||||
|
import { formatDuration, formatDateRange, dayOf } from '$lib/time'
|
||||||
|
import Avatar from '$lib/components/Avatar.svelte'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import * as Table from '@/components/ui/table'
|
||||||
|
import { ArrowLeft, Calendar, ChevronRight, Crown, Medal, Shield, Timer, UserCheck, Users } from 'lucide-svelte'
|
||||||
|
|
||||||
|
const teams = getTeamContext()
|
||||||
|
const events = getEventContext()
|
||||||
|
const stages = getStageContext()
|
||||||
|
const riders = getRiderContext()
|
||||||
|
const times = getTimeContext()
|
||||||
|
const participants = getEventParticipantContext()
|
||||||
|
|
||||||
|
const team = $derived(teams.active)
|
||||||
|
|
||||||
|
// Gezeigt wird nur, wer zum aktiven Team gehört — `scoped` statt
|
||||||
|
// `getById`, sonst stünde über einen geratenen Link ein fremder Fahrer da.
|
||||||
|
const rider = $derived(riders.scoped.find((r) => r.id === page.params.riderId))
|
||||||
|
|
||||||
|
const account = $derived(rider?.user ? teams.users[rider.user] : null)
|
||||||
|
|
||||||
|
const role = $derived.by(() => {
|
||||||
|
const uid = rider?.user
|
||||||
|
if (!uid || !team) return null
|
||||||
|
if (team.owner === uid) return 'owner'
|
||||||
|
return (team.admins ?? []).includes(uid) ? 'admin' : 'mitglied'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Zeile je Teilnahme, das jüngste Event zuerst. Gerechnet wird je
|
||||||
|
* Event über dessen Stages: Zeiten hängen an der Stage, und nur über die
|
||||||
|
* hängen sie am Event.
|
||||||
|
*/
|
||||||
|
const rows = $derived.by(() => {
|
||||||
|
if (!rider) return []
|
||||||
|
|
||||||
|
const own = times.getByRider(rider.id)
|
||||||
|
|
||||||
|
return participants
|
||||||
|
.byRider(rider.id)
|
||||||
|
.map((entry) => {
|
||||||
|
const event = events.getById(entry.event)
|
||||||
|
const eventStages = stages.getByEvent(entry.event)
|
||||||
|
|
||||||
|
// Je Stage zählt die schnellste gewertete Zeit — mehrere
|
||||||
|
// Versuche sind ein erledigter Lauf, nicht zwei.
|
||||||
|
const best = eventStages
|
||||||
|
.map((stage) => ({
|
||||||
|
stage,
|
||||||
|
time: own
|
||||||
|
.filter((t) => t.stage === stage.id && t.status === 'finished')
|
||||||
|
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))[0],
|
||||||
|
}))
|
||||||
|
.filter((s) => s.time)
|
||||||
|
|
||||||
|
return {
|
||||||
|
entry,
|
||||||
|
event,
|
||||||
|
of: eventStages.length,
|
||||||
|
done: best.length,
|
||||||
|
total: best.reduce((sum, s) => sum + (s.time?.duration ?? 0), 0),
|
||||||
|
best,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const da = dayOf(a.event?.starts)
|
||||||
|
const db = dayOf(b.event?.starts)
|
||||||
|
if (!da && !db) return 0
|
||||||
|
if (!da) return 1
|
||||||
|
if (!db) return -1
|
||||||
|
return da < db ? 1 : da > db ? -1 : 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const stats = $derived.by(() => {
|
||||||
|
const all = rows.flatMap((r) => r.best)
|
||||||
|
const durations = all.map((s) => s.time?.duration ?? 0).filter((d) => d > 0)
|
||||||
|
|
||||||
|
const podium = rider
|
||||||
|
? all.filter((s) => {
|
||||||
|
const p = times.placement(s.stage.id, rider.id)
|
||||||
|
return p !== null && p <= 3
|
||||||
|
}).length
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
events: rows.length,
|
||||||
|
confirmed: rows.filter((r) => r.entry.status === 'zugesagt').length,
|
||||||
|
stages: all.length,
|
||||||
|
best: durations.length > 0 ? Math.min(...durations) : null,
|
||||||
|
podium,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{rider ? riderName(rider) || 'Fahrer' : 'Fahrer'} · Kurbeler</title></svelte:head>
|
||||||
|
|
||||||
|
{#if !rider}
|
||||||
|
<Card>
|
||||||
|
<CardContent class="py-16 text-center">
|
||||||
|
<Users class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p class="text-lg font-medium">Fahrer nicht gefunden</p>
|
||||||
|
<p class="text-sm text-muted-foreground mt-1">
|
||||||
|
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
|
||||||
|
</p>
|
||||||
|
<Button href="/dashboard/team" variant="outline" class="mt-4">
|
||||||
|
<ArrowLeft class="h-4 w-4 mr-2" />
|
||||||
|
Zum Team
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="/dashboard/team"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="h-3 w-3" />
|
||||||
|
Team
|
||||||
|
</a>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Avatar user={account} size={48} />
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">
|
||||||
|
{riderName(rider) || 'Ohne Name'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{#if role === 'owner'}
|
||||||
|
<Badge class="gap-1"><Crown class="size-3" />Owner</Badge>
|
||||||
|
{:else if role === 'admin'}
|
||||||
|
<Badge variant="secondary" class="gap-1"><Shield class="size-3" />Admin</Badge>
|
||||||
|
{:else if role === 'mitglied'}
|
||||||
|
<Badge variant="outline">Mitglied</Badge>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if rider.user}
|
||||||
|
<Badge variant="secondary" class="gap-1">
|
||||||
|
<UserCheck class="size-3" />
|
||||||
|
kann sich anmelden
|
||||||
|
</Badge>
|
||||||
|
{:else}
|
||||||
|
<span class="text-sm text-muted-foreground">kein Konto</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-sm font-medium text-muted-foreground">Events</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="text-3xl font-bold">{stats.events}</div>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">{stats.confirmed} zugesagt</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-sm font-medium text-muted-foreground">Gefahrene Stages</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="text-3xl font-bold">{stats.stages}</div>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">gewertet</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-sm font-medium text-muted-foreground">Beste Stage-Zeit</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="text-3xl font-bold tabular-nums">
|
||||||
|
{stats.best != null ? formatDuration(stats.best) : '—'}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-sm font-medium text-muted-foreground">Podestplätze</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="text-3xl font-bold">{stats.podium}</div>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">Top 3 einer Stage</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<Calendar class="h-4 w-4 text-primary" />
|
||||||
|
Teilnahmen ({rows.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-0">
|
||||||
|
{#if rows.length === 0}
|
||||||
|
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
|
||||||
|
Dieser Fahrer ist bei keinem Event eingetragen.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Event</Table.Head>
|
||||||
|
<Table.Head class="w-40">Datum</Table.Head>
|
||||||
|
<Table.Head class="w-20">Nr.</Table.Head>
|
||||||
|
<Table.Head class="w-28">Status</Table.Head>
|
||||||
|
<Table.Head class="w-32">Stages</Table.Head>
|
||||||
|
<Table.Head class="w-28 text-right">Gesamtzeit</Table.Head>
|
||||||
|
<Table.Head class="w-10"></Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each rows as row (row.entry.id)}
|
||||||
|
{@const path = `/dashboard/events/${row.entry.event}/riders/${rider.id}`}
|
||||||
|
<Table.Row class="cursor-pointer" onclick={() => goto(path)}>
|
||||||
|
<Table.Cell class="font-medium">
|
||||||
|
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
|
||||||
|
<a href={path} class="hover:underline">
|
||||||
|
{row.event?.name || row.event?.description || 'Event'}
|
||||||
|
</a>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-sm text-muted-foreground">
|
||||||
|
{formatDateRange(row.event?.starts, row.event?.ends) || '—'}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge variant="outline">{row.entry.number || '—'}</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge
|
||||||
|
variant={row.entry.status === 'zugesagt'
|
||||||
|
? 'default'
|
||||||
|
: row.entry.status === 'angefragt'
|
||||||
|
? 'secondary'
|
||||||
|
: 'outline'}
|
||||||
|
>
|
||||||
|
{PARTICIPANT_STATUS_LABEL[row.entry.status] ?? row.entry.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-sm text-muted-foreground">
|
||||||
|
<!-- Ohne Stages gibt es nichts zu zählen; „0 von 0" wäre nur Lärm. -->
|
||||||
|
{row.of === 0 ? '—' : `${row.done} von ${row.of} Stages`}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-right tabular-nums">
|
||||||
|
{row.done > 0 ? formatDuration(row.total) : '—'}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<ChevronRight class="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -7,14 +7,23 @@
|
||||||
* keine Einstellung, sondern der Gegenstand der Anwendung.
|
* keine Einstellung, sondern der Gegenstand der Anwendung.
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
import { Flag, User } from 'lucide-svelte'
|
import { auth } from '$lib/stores/pocketbase.svelte'
|
||||||
|
import { Bell, Flag, User, Users } from 'lucide-svelte'
|
||||||
|
|
||||||
let { children } = $props()
|
let { children } = $props()
|
||||||
|
|
||||||
const tabs = [
|
// Das Benutzerverzeichnis ist die eine Ausnahme von „hier steht nur, was
|
||||||
|
// einen selbst betrifft": Es führt alle Konten der App und erscheint nur
|
||||||
|
// dem Superadmin. Ausgeblendet heißt nicht abgesichert — die Seite selbst
|
||||||
|
// weist ab, und der Hook dahinter tut es auch.
|
||||||
|
const tabs = $derived([
|
||||||
{ href: '/dashboard/settings', label: 'Profil', icon: User },
|
{ href: '/dashboard/settings', label: 'Profil', icon: User },
|
||||||
|
{ href: '/dashboard/settings/notifications', label: 'Benachrichtigungen', icon: Bell },
|
||||||
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
|
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
|
||||||
]
|
...(auth.user?.superadmin
|
||||||
|
? [{ href: '/dashboard/settings/users', label: 'Benutzer', icon: Users }]
|
||||||
|
: []),
|
||||||
|
])
|
||||||
|
|
||||||
// Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter
|
// Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter
|
||||||
// auf jedem Unterpfad mit aktiv.
|
// auf jedem Unterpfad mit aktiv.
|
||||||
|
|
@ -25,7 +34,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Einstellungen</title></svelte:head>
|
<svelte:head><title>Einstellungen · Kurbeler</title></svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<h1 class="text-3xl font-bold tracking-tight">Einstellungen</h1>
|
<h1 class="text-3xl font-bold tracking-tight">Einstellungen</h1>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Worüber die App per Mail berichtet.
|
||||||
|
*
|
||||||
|
* Es gibt genau eine Mail am Tag, morgens um sechs, mit allem, was seit
|
||||||
|
* der letzten passiert ist — nicht eine Mail je Ereignis. Das steht hier
|
||||||
|
* ausdrücklich, weil sonst niemand einen Schalter umlegt: Wer „neuer
|
||||||
|
* Kommentar" liest, rechnet mit einer Mail je Kommentar und lässt es
|
||||||
|
* lieber aus.
|
||||||
|
*
|
||||||
|
* Gespeichert wird sofort beim Umlegen, ohne Knopf — wie Name und Bild im
|
||||||
|
* Profil. Der Datensatz entsteht dabei erst beim ersten Mal; bis dahin
|
||||||
|
* zeigen die Schalter die Voreinstellungen, und die sind kein Platzhalter,
|
||||||
|
* sondern genau das, was der Server tut.
|
||||||
|
*/
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import {
|
||||||
|
notificationPrefs as prefs,
|
||||||
|
NOTIFICATION_GROUPS,
|
||||||
|
type NotificationType,
|
||||||
|
} from '$lib/stores/notificationPrefs.svelte'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { Bell, BellOff, Clock } from 'lucide-svelte'
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void prefs.load()
|
||||||
|
})
|
||||||
|
|
||||||
|
const off = $derived(!prefs.values.enabled)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="max-w-2xl space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
{#if off}
|
||||||
|
<BellOff class="size-4 text-muted-foreground" />
|
||||||
|
{:else}
|
||||||
|
<Bell class="size-4 text-primary" />
|
||||||
|
{/if}
|
||||||
|
Benachrichtigungen
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<!--
|
||||||
|
Der Satz steht vor den Schaltern und nicht darunter: Er ist die
|
||||||
|
Bedingung, unter der man sie überhaupt sinnvoll stellt.
|
||||||
|
-->
|
||||||
|
<div class="flex gap-3 rounded-lg border bg-muted/40 p-3">
|
||||||
|
<Clock class="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Es geht <strong class="text-foreground">eine Mail am Tag</strong> raus, morgens
|
||||||
|
gegen sechs, mit allem, was seit der letzten passiert ist. Kein Ereignis
|
||||||
|
löst für sich eine Mail aus. Ist nichts passiert, kommt auch nichts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={prefs.values.enabled}
|
||||||
|
onchange={(e) => prefs.set('enabled', e.currentTarget.checked)}
|
||||||
|
class="mt-0.5 size-4 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span class="font-medium">Tageszusammenfassung bekommen</span>
|
||||||
|
<span class="block text-xs text-muted-foreground">
|
||||||
|
Aus heißt: gar keine Mails, unabhängig von allem darunter. Für die
|
||||||
|
zwei Wochen, in denen Ruhe sein soll, ohne danach alles neu
|
||||||
|
zusammenzuklicken.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if prefs.error}
|
||||||
|
<p class="text-sm text-destructive">{prefs.error}</p>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Bei abgeschalteter Zusammenfassung bleiben die Schalter bedienbar, aber
|
||||||
|
sichtbar wirkungslos. Sie auszublenden hieße, jemandem seine eigenen
|
||||||
|
Einstellungen wegzunehmen, während er sie nur pausiert hat.
|
||||||
|
-->
|
||||||
|
<div class:opacity-50={off} class="space-y-6 transition-opacity">
|
||||||
|
{#each NOTIFICATION_GROUPS as group (group.title)}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">{group.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
{#each group.items as item, i (item.key)}
|
||||||
|
{#if i > 0}
|
||||||
|
<Separator />
|
||||||
|
{/if}
|
||||||
|
<label class="flex items-start gap-3 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={prefs.values[item.key as NotificationType]}
|
||||||
|
onchange={(e) =>
|
||||||
|
prefs.set(item.key, e.currentTarget.checked)}
|
||||||
|
class="mt-0.5 size-4 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span class="font-medium">{item.label}</span>
|
||||||
|
<span class="block text-xs text-muted-foreground">{item.hint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
245
frontend/src/routes/dashboard/settings/users/+page.svelte
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Das Benutzerverzeichnis — alle Konten der App, nur für Superadmins.
|
||||||
|
*
|
||||||
|
* Warum hier und nirgends sonst: Ein Konto gehört keinem Team. Die
|
||||||
|
* Teamleitung verwaltet Fahrer und Mitgliedschaften und entzieht im Kader
|
||||||
|
* höchstens den Zugang; an ein fremdes Konto kommt sie nicht heran, und
|
||||||
|
* `users.deleteRule` erzwingt das auch serverseitig. Bleibt der Fall, den
|
||||||
|
* bis hierher niemand erledigen konnte: ein Konto, das niemand mehr
|
||||||
|
* braucht und dessen Inhaber sich nicht selbst darum kümmert.
|
||||||
|
*
|
||||||
|
* Die Liste kommt aus `/api/directory/users` und nicht aus der Collection:
|
||||||
|
* Konten entstehen mit `emailVisibility: false`, die Adresse steht in
|
||||||
|
* keinem `users`-Datensatz, den jemand anderes liest. Der Hook gibt sie
|
||||||
|
* dem Superadmin heraus — und nur ihm.
|
||||||
|
*/
|
||||||
|
import { api, auth } from '$lib/stores/pocketbase.svelte'
|
||||||
|
import { app, tooltip } from '$lib/stores/app.svelte'
|
||||||
|
import { formatDay } from '$lib/time'
|
||||||
|
import Avatar from '$lib/components/Avatar.svelte'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import * as Table from '@/components/ui/table'
|
||||||
|
import { Crown, Search, ShieldAlert, Trash2, Users } from 'lucide-svelte'
|
||||||
|
|
||||||
|
type DirectoryUser = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
verified: boolean
|
||||||
|
superadmin: boolean
|
||||||
|
created: string
|
||||||
|
avatar: string
|
||||||
|
teams: string[]
|
||||||
|
owns: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = $derived(!!auth.user?.superadmin)
|
||||||
|
|
||||||
|
let records = $state<DirectoryUser[]>([])
|
||||||
|
let loading = $state(false)
|
||||||
|
let error = $state<string | null>(null)
|
||||||
|
let query = $state('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!allowed) return
|
||||||
|
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
try {
|
||||||
|
const res = await api.send('/api/directory/users', { method: 'GET' })
|
||||||
|
records = (res?.users ?? []) as DirectoryUser[]
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message ?? 'Das Verzeichnis konnte nicht geladen werden.'
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (allowed && records.length === 0 && !loading && !error) void load()
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const q = query.toLowerCase().trim()
|
||||||
|
const list = q
|
||||||
|
? records.filter(
|
||||||
|
(u) =>
|
||||||
|
u.name.toLowerCase().includes(q) ||
|
||||||
|
u.email.toLowerCase().includes(q) ||
|
||||||
|
u.teams.some((t) => t.toLowerCase().includes(q)),
|
||||||
|
)
|
||||||
|
: records
|
||||||
|
|
||||||
|
return [...list].sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email))
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warum dieses Konto nicht fallen darf — leer heißt: es darf.
|
||||||
|
*
|
||||||
|
* Das eigene steht bewusst nicht zur Wahl: Es geht, aber nicht von hier
|
||||||
|
* aus. Wer sich selbst löscht, meldet sich damit ab und verliert mitten
|
||||||
|
* in der Verwaltung den Boden; dafür gibt es den Weg über das eigene
|
||||||
|
* Profil, der genau das erwartet.
|
||||||
|
*
|
||||||
|
* Ein Team-Owner geht gar nicht. `teams.owner` ist ein Pflichtfeld ohne
|
||||||
|
* Kaskade — PocketBase müsste es beim Löschen leeren und bräche dabei den
|
||||||
|
* Datensatz. Der Versuch endete in einem Datenbankfehler, den niemand
|
||||||
|
* deuten kann, deshalb steht die Bedingung hier vorne und im Klartext.
|
||||||
|
*/
|
||||||
|
function blockedReason(u: DirectoryUser): string {
|
||||||
|
if (u.id === auth.user?.id) {
|
||||||
|
return 'Das eigene Konto löschst du in deinem Profil.'
|
||||||
|
}
|
||||||
|
if (u.owns.length > 0) {
|
||||||
|
const list = u.owns.join('“, „')
|
||||||
|
return `Besitzt das Team „${list}“. Erst das Team übergeben oder löschen.`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(u: DirectoryUser) {
|
||||||
|
if (blockedReason(u)) return
|
||||||
|
|
||||||
|
app.confirm.request({
|
||||||
|
title: 'Konto löschen?',
|
||||||
|
text:
|
||||||
|
`Das Konto von „${u.name || u.email}“ wird endgültig gelöscht. ` +
|
||||||
|
`Fahrer, Teilnahmen und Zeiten bleiben in ihren Teams stehen — ` +
|
||||||
|
`sie gehören dem Team und nicht dem Login. Über einen Einladungslink ` +
|
||||||
|
`kann sich die Person ein neues Konto anlegen.`,
|
||||||
|
yes: async () => {
|
||||||
|
try {
|
||||||
|
await api.collection('users').delete(u.id)
|
||||||
|
records = records.filter((r) => r.id !== u.id)
|
||||||
|
app.confirm.close()
|
||||||
|
} catch (e: any) {
|
||||||
|
app.confirm.close()
|
||||||
|
error = e.message ?? 'Das Konto konnte nicht gelöscht werden.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
no: () => app.confirm.close(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !allowed}
|
||||||
|
<Card>
|
||||||
|
<CardContent class="py-16 text-center">
|
||||||
|
<ShieldAlert class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p class="text-lg font-medium">Nur für Superadmins</p>
|
||||||
|
<p class="text-sm text-muted-foreground mt-1">
|
||||||
|
Diese Seite führt alle Konten der App. Die Rolle wird direkt in PocketBase vergeben.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<Users class="size-4 text-primary" />
|
||||||
|
Benutzer
|
||||||
|
<span class="text-muted-foreground font-normal">({records.length})</span>
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<div class="relative w-full max-w-xs">
|
||||||
|
<Search
|
||||||
|
class="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<Input bind:value={query} placeholder="Name, E-Mail oder Team" class="pl-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="p-0">
|
||||||
|
{#if error}
|
||||||
|
<p class="px-6 pb-4 text-sm text-destructive">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading && records.length === 0}
|
||||||
|
<p class="px-6 py-8 text-center text-sm text-muted-foreground">Wird geladen …</p>
|
||||||
|
{:else if filtered.length === 0}
|
||||||
|
<p class="px-6 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{records.length === 0 ? 'Keine Konten.' : 'Keine Treffer.'}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Name</Table.Head>
|
||||||
|
<Table.Head class="w-64">E-Mail</Table.Head>
|
||||||
|
<Table.Head>Teams</Table.Head>
|
||||||
|
<Table.Head class="w-28">Angelegt</Table.Head>
|
||||||
|
<Table.Head class="w-16 text-right">Aktion</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each filtered as u (u.id)}
|
||||||
|
{@const blocked = blockedReason(u)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-medium">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<!-- collectionName ergaenzt, damit getFileURL das Bild findet:
|
||||||
|
der Hook liefert einen schlanken Datensatz, keinen Record. -->
|
||||||
|
<Avatar user={{ ...u, collectionName: 'users' }} size={28} />
|
||||||
|
{u.name || '— ohne Name —'}
|
||||||
|
{#if u.superadmin}
|
||||||
|
<Badge variant="secondary" class="gap-1">
|
||||||
|
<Crown class="size-3" />
|
||||||
|
Superadmin
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if !u.verified}
|
||||||
|
<Badge variant="outline">unbestätigt</Badge>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
<Table.Cell class="text-sm text-muted-foreground">
|
||||||
|
<span class="block truncate">{u.email}</span>
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
<Table.Cell class="text-sm text-muted-foreground">
|
||||||
|
{#if u.teams.length === 0}
|
||||||
|
<span class="italic">in keinem Team</span>
|
||||||
|
{:else}
|
||||||
|
{u.teams.join(', ')}
|
||||||
|
{/if}
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
<Table.Cell class="text-sm text-muted-foreground">
|
||||||
|
{formatDay(u.created) || '—'}
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
<Table.Cell class="text-right">
|
||||||
|
{#if blocked}
|
||||||
|
<!-- Kein abgeschalteter Knopf ohne Begruendung: Der Grund
|
||||||
|
haengt am Zeichen, sonst raet man, warum nichts geht. -->
|
||||||
|
<span class="inline-flex" use:tooltip={{ content: blocked }}>
|
||||||
|
<Button variant="ghost" size="icon" disabled>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
tooltip="Konto löschen"
|
||||||
|
onclick={() => remove(u)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
@ -20,13 +20,13 @@
|
||||||
* auch die Teamleitung nicht heran. Beim Anlegen setzt riders.createLogin
|
* auch die Teamleitung nicht heran. Beim Anlegen setzt riders.createLogin
|
||||||
* den Kontonamen einmal auf den Fahrernamen; ab da zählt der Fahrer.
|
* den Kontonamen einmal auf den Fahrernamen; ab da zählt der Fahrer.
|
||||||
*/
|
*/
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
import { api } from '$lib/stores/pocketbase.svelte'
|
import { api } from '$lib/stores/pocketbase.svelte'
|
||||||
|
import { app, tooltip } from '$lib/stores/app.svelte'
|
||||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||||
import { getTeamInviteContext } from '$lib/stores/teamInvites.svelte'
|
import { getTeamInviteContext } from '$lib/stores/teamInvites.svelte'
|
||||||
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getEventContext } from '$lib/stores/events.svelte'
|
import { getEventContext } from '$lib/stores/events.svelte'
|
||||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
|
||||||
import { getTrailContext } from '$lib/stores/trails.svelte'
|
import { getTrailContext } from '$lib/stores/trails.svelte'
|
||||||
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
||||||
import Avatar from '$lib/components/Avatar.svelte'
|
import Avatar from '$lib/components/Avatar.svelte'
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
import * as Table from '@/components/ui/table'
|
import * as Table from '@/components/ui/table'
|
||||||
import {
|
import {
|
||||||
Bike, Calendar, Check, Copy, Crown, Image, KeyRound, Link2, LogOut, Pencil,
|
Bike, Calendar, Check, Copy, Crown, Image, KeyRound, Link2, LogOut, Pencil,
|
||||||
Plus, Route, Search, Shield, Trash2, UserCheck, UserMinus, Users,
|
Plus, Route, Search, Shield, Trash2, UserMinus, Users,
|
||||||
} from 'lucide-svelte'
|
} from 'lucide-svelte'
|
||||||
import type { RidersResponse, TeamInvitesResponse, TeamsResponse } from '$lib/types'
|
import type { RidersResponse, TeamInvitesResponse, TeamsResponse } from '$lib/types'
|
||||||
|
|
||||||
|
|
@ -50,8 +50,6 @@
|
||||||
const invites = getTeamInviteContext()
|
const invites = getTeamInviteContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
const times = getTimeContext()
|
|
||||||
const participants = getEventParticipantContext()
|
|
||||||
const trails = getTrailContext()
|
const trails = getTrailContext()
|
||||||
const markers = getTrailMarkerContext()
|
const markers = getTrailMarkerContext()
|
||||||
|
|
||||||
|
|
@ -133,33 +131,36 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zur Fahrerseite geht es über ein Event, denn Zeiten hängen an der Stage und
|
* Aus dem Kader geht es aufs Fahrerprofil, nicht in ein Event.
|
||||||
* der am Event. Bei mehreren Teilnahmen führt der Link zur jüngsten.
|
*
|
||||||
|
* Früher führte der Link über die jüngste Teilnahme auf die
|
||||||
|
* Event-Fahrerseite — wer im Team auf einen Namen klickte, landete in
|
||||||
|
* einem einzelnen Rennen, das er gar nicht gesucht hatte. Das Profil
|
||||||
|
* fasst alle Teilnahmen zusammen; von dort geht es ins einzelne Event.
|
||||||
*/
|
*/
|
||||||
function riderPath(riderId: string) {
|
function riderPath(riderId: string) {
|
||||||
const latest = participants.byRider(riderId).at(-1)
|
return `/dashboard/riders/${riderId}`
|
||||||
return latest ? `/dashboard/events/${latest.event}/riders/${riderId}` : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Person anlegen oder bearbeiten ----------------------------------
|
// ---- Fahrer anlegen oder bearbeiten ----------------------------------
|
||||||
//
|
//
|
||||||
// Der Dialog bearbeitet die Person, nicht nur den Fahrer: Name und Rolle
|
// Der Dialog fasst zusammen, was zu einem Fahrer gehoert: sein Name und
|
||||||
// im Team stehen beieinander. Die Rolle lag früher als „Zu Admin" in der
|
// seine Rolle im Team stehen beieinander. Die Rolle lag früher als „Zu Admin" in der
|
||||||
// Zeile — ein Knopf, der ohne Rückfrage Rechte vergab, direkt neben den
|
// Zeile — ein Knopf, der ohne Rückfrage Rechte vergab, direkt neben den
|
||||||
// Icons fürs Entfernen und Löschen.
|
// Icons fürs Entfernen und Löschen.
|
||||||
//
|
//
|
||||||
// `editing` ist der Fahrer und darf leer bleiben: Alte Datensätze kennen
|
// `editing` ist der Fahrer und darf leer bleiben: Alte Datensätze kennen
|
||||||
// Konten im Team, an denen kein Fahrer hängt. Ob der Dialog anlegt oder
|
// Konten im Team, an denen kein Fahrer hängt. Ob der Dialog anlegt oder
|
||||||
// bearbeitet, sagt deshalb `isEdit`.
|
// bearbeitet, sagt deshalb `isEdit`.
|
||||||
let personDialog = $state(false)
|
let riderDialog = $state(false)
|
||||||
let isEdit = $state(false)
|
let isEdit = $state(false)
|
||||||
let editing = $state<RidersResponse | null>(null)
|
let editing = $state<RidersResponse | null>(null)
|
||||||
let editingUser = $state<string | null>(null)
|
let editingUser = $state<string | null>(null)
|
||||||
let form = $state({ name: '', withAccount: false, email: '', password: '', admin: false })
|
let form = $state({ name: '', withAccount: false, email: '', admin: false })
|
||||||
let busy = $state(false)
|
let busy = $state(false)
|
||||||
let error = $state<string | null>(null)
|
let error = $state<string | null>(null)
|
||||||
|
|
||||||
/** Rolle der gerade bearbeiteten Person, mitlaufend mit dem Team. */
|
/** Rolle des gerade bearbeiteten Fahrers, mitlaufend mit dem Team. */
|
||||||
const editingRole = $derived(roleOf(editingUser ?? undefined))
|
const editingRole = $derived(roleOf(editingUser ?? undefined))
|
||||||
|
|
||||||
/** Die Rolle vergibt allein der Owner, und nie die eigene. */
|
/** Die Rolle vergibt allein der Owner, und nie die eigene. */
|
||||||
|
|
@ -169,9 +170,9 @@
|
||||||
isEdit = false
|
isEdit = false
|
||||||
editing = null
|
editing = null
|
||||||
editingUser = null
|
editingUser = null
|
||||||
form = { name: '', withAccount: false, email: '', password: '', admin: false }
|
form = { name: '', withAccount: false, email: '', admin: false }
|
||||||
error = null
|
error = null
|
||||||
personDialog = true
|
riderDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(row: Row) {
|
function openEdit(row: Row) {
|
||||||
|
|
@ -182,27 +183,20 @@
|
||||||
name: row.rider?.name ?? '',
|
name: row.rider?.name ?? '',
|
||||||
withAccount: false,
|
withAccount: false,
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
|
||||||
admin: row.role === 'admin',
|
admin: row.role === 'admin',
|
||||||
}
|
}
|
||||||
error = null
|
error = null
|
||||||
personDialog = true
|
riderDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePerson() {
|
async function saveRider() {
|
||||||
if ((editing || !isEdit) && !form.name.trim()) {
|
if ((editing || !isEdit) && !form.name.trim()) {
|
||||||
error = 'Bitte einen Namen angeben.'
|
error = 'Bitte einen Namen angeben.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (form.withAccount) {
|
if (form.withAccount && !form.email.trim()) {
|
||||||
if (!form.email.trim()) {
|
error = 'Für die Einladung braucht es eine E-Mail-Adresse.'
|
||||||
error = 'Für ein Konto braucht es eine E-Mail-Adresse.'
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
if (form.password.length < 8) {
|
|
||||||
error = 'Das Passwort braucht mindestens 8 Zeichen.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
busy = true
|
busy = true
|
||||||
|
|
@ -221,74 +215,111 @@
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const rider = await riders.create(data)
|
const rider = await riders.create(data)
|
||||||
|
|
||||||
|
// Erst der Fahrer, dann die Einladung: Sie hängt an ihm, und
|
||||||
|
// scheitert der Versand, steht der Fahrer trotzdem im Kader.
|
||||||
|
// Die Teamleitung kann ihn dann von der Zeile aus erneut
|
||||||
|
// einladen, statt alles noch einmal einzutippen.
|
||||||
if (form.withAccount) {
|
if (form.withAccount) {
|
||||||
await riders.createLogin(rider as RidersResponse, {
|
await invites.send({
|
||||||
email: form.email,
|
rider: rider.id,
|
||||||
password: form.password,
|
email: form.email.trim(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
personDialog = false
|
riderDialog = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e.message ?? 'Die Person konnte nicht gespeichert werden.'
|
error = e.message ?? 'Der Fahrer konnte nicht gespeichert werden.'
|
||||||
} finally {
|
} finally {
|
||||||
busy = false
|
busy = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Konto zu einem vorhandenen Fahrer -------------------------------
|
// ---- Einladung an einen vorhandenen Fahrer ---------------------------
|
||||||
//
|
//
|
||||||
// Nur diese eine Richtung: aus einem Fahrer ohne Konto einen mit Konto
|
// Nur diese eine Richtung: aus einem Fahrer ohne Konto einen mit Konto
|
||||||
// machen. Ein bestehendes Konto an einen Fahrer zu hängen oder die
|
// machen. Ein bestehendes Konto an einen Fahrer zu hängen oder die
|
||||||
// Verbindung wieder zu lösen gibt es bewusst nicht — wer sich anmelden
|
// Verbindung wieder zu lösen gibt es bewusst nicht — wer sich anmelden
|
||||||
// können soll, bekommt hier ein Konto oder kommt über den Einladungslink.
|
// können soll, wird hier eingeladen oder kommt über den Einladungslink.
|
||||||
|
//
|
||||||
|
// Hier stand früher ein Passwortfeld. Die Teamleitung dachte sich eins
|
||||||
|
// aus, gab es irgendwie weiter — und kannte danach das Passwort eines
|
||||||
|
// anderen. Jetzt geht eine Mail raus und der Fahrer setzt es selbst;
|
||||||
|
// niemand sonst kennt es je.
|
||||||
let accountDialog = $state(false)
|
let accountDialog = $state(false)
|
||||||
let accountRider = $state<RidersResponse | null>(null)
|
let accountRider = $state<RidersResponse | null>(null)
|
||||||
let accountForm = $state({ email: '', password: '' })
|
let accountForm = $state({ email: '', days: 14 })
|
||||||
let accountBusy = $state(false)
|
let accountBusy = $state(false)
|
||||||
let accountError = $state<string | null>(null)
|
let accountError = $state<string | null>(null)
|
||||||
|
let accountSent = $state<string | null>(null)
|
||||||
|
|
||||||
function openAccount(rider: RidersResponse) {
|
function openAccount(rider: RidersResponse) {
|
||||||
accountRider = rider
|
accountRider = rider
|
||||||
accountForm = { email: '', password: '' }
|
accountForm = { email: '', days: 14 }
|
||||||
accountError = null
|
accountError = null
|
||||||
|
accountSent = null
|
||||||
accountDialog = true
|
accountDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAccount() {
|
async function sendInvite() {
|
||||||
if (!accountRider) return
|
if (!accountRider) return
|
||||||
|
|
||||||
if (!accountForm.email.trim()) {
|
if (!accountForm.email.trim()) {
|
||||||
accountError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
|
accountError = 'Ohne E-Mail-Adresse geht keine Einladung raus.'
|
||||||
return
|
|
||||||
}
|
|
||||||
if (accountForm.password.length < 8) {
|
|
||||||
accountError = 'Das Passwort braucht mindestens 8 Zeichen.'
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accountBusy = true
|
accountBusy = true
|
||||||
accountError = null
|
accountError = null
|
||||||
try {
|
try {
|
||||||
await riders.createLogin(accountRider, { ...accountForm })
|
await invites.send({
|
||||||
accountDialog = false
|
rider: accountRider.id,
|
||||||
|
email: accountForm.email.trim(),
|
||||||
|
days: accountForm.days,
|
||||||
|
})
|
||||||
|
accountSent = accountForm.email.trim()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
accountError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
|
accountError = e.message ?? 'Die Einladung konnte nicht verschickt werden.'
|
||||||
} finally {
|
} finally {
|
||||||
accountBusy = false
|
accountBusy = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeFromTeam(userId: string) {
|
/**
|
||||||
if (!team) return
|
* Zugang entziehen — der einzige Eingriff, den die Teamleitung an einer
|
||||||
if (userId === team.owner) return alert('Der Owner kann nicht entfernt werden.')
|
* fremden Person hat.
|
||||||
if (!confirm('Konto aus dem Team entfernen? Der Fahrer bleibt im Kader.')) return
|
*
|
||||||
|
* Sie nimmt das Konto aus `teams.users` und `teams.admins`. Damit sieht
|
||||||
|
* die Person dieses Team nicht mehr; das Konto selbst bleibt unberührt,
|
||||||
|
* und PocketBase erzwingt das auch: `users.deleteRule` lässt ein Konto
|
||||||
|
* nur von seinem Inhaber löschen oder vom Superadmin.
|
||||||
|
*
|
||||||
|
* Der Fahrer bleibt im Kader, mit Teilnahmen und Zeiten. Das ist der
|
||||||
|
* Kern: Wer ausgetreten ist, verschwindet nicht rückwirkend aus den
|
||||||
|
* Ergebnissen vergangener Rennen — die Geschichte gehört dem Team, nicht
|
||||||
|
* dem Login. Ein „Fahrer löschen" gibt es hier deshalb nicht mehr; es
|
||||||
|
* hätte Zeiten mitgerissen und stand irreführend neben diesem Knopf.
|
||||||
|
*/
|
||||||
|
function removeFromTeam(userId: string, name: string) {
|
||||||
|
if (!team || userId === team.owner) return
|
||||||
|
|
||||||
try {
|
app.confirm.request({
|
||||||
await teams.removeMember(team.id, userId)
|
title: 'Zugang entziehen?',
|
||||||
} catch (e: any) {
|
text:
|
||||||
alert(e.message ?? 'Das Mitglied konnte nicht entfernt werden.')
|
`„${name}" sieht dieses Team danach nicht mehr. Das Konto bleibt bestehen — ` +
|
||||||
}
|
`es kann über einen Einladungslink zurückkommen. Fahrer, Teilnahmen und Zeiten ` +
|
||||||
|
`bleiben im Kader stehen.`,
|
||||||
|
yes: async () => {
|
||||||
|
try {
|
||||||
|
await teams.removeMember(team.id, userId)
|
||||||
|
app.confirm.close()
|
||||||
|
} catch (e: any) {
|
||||||
|
app.confirm.close()
|
||||||
|
alert(e.message ?? 'Der Zugang konnte nicht entzogen werden.')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
no: () => app.confirm.close(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Einladungslinks -------------------------------------------------
|
// ---- Einladungslinks -------------------------------------------------
|
||||||
|
|
@ -419,7 +450,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Team</title></svelte:head>
|
<svelte:head><title>Team · Kurbeler</title></svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
|
@ -517,7 +548,7 @@
|
||||||
{#if canManage}
|
{#if canManage}
|
||||||
<Button onclick={openCreate}>
|
<Button onclick={openCreate}>
|
||||||
<Plus class="size-4 mr-2" />
|
<Plus class="size-4 mr-2" />
|
||||||
Person anlegen
|
Fahrer anlegen
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -530,7 +561,7 @@
|
||||||
<p class="font-medium">Noch niemand im Team</p>
|
<p class="font-medium">Noch niemand im Team</p>
|
||||||
<p class="text-sm text-muted-foreground mt-1">
|
<p class="text-sm text-muted-foreground mt-1">
|
||||||
{#if canManage}
|
{#if canManage}
|
||||||
Leg die erste Person an oder verschick einen Einladungslink.
|
Leg den ersten Fahrer an oder verschick einen Einladungslink.
|
||||||
{:else}
|
{:else}
|
||||||
Die Teamleitung legt den Kader an.
|
Die Teamleitung legt den Kader an.
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -543,10 +574,7 @@
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head>Name</Table.Head>
|
<Table.Head>Name</Table.Head>
|
||||||
<Table.Head class="w-48">Konto</Table.Head>
|
|
||||||
<Table.Head class="w-32">Rolle</Table.Head>
|
<Table.Head class="w-32">Rolle</Table.Head>
|
||||||
<Table.Head class="w-24">Events</Table.Head>
|
|
||||||
<Table.Head class="w-24 text-right">Zeiten</Table.Head>
|
|
||||||
{#if canManage}
|
{#if canManage}
|
||||||
<Table.Head class="text-right">Aktionen</Table.Head>
|
<Table.Head class="text-right">Aktionen</Table.Head>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -555,7 +583,24 @@
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each filtered as row (row.rider?.id ?? row.userId)}
|
{#each filtered as row (row.rider?.id ?? row.userId)}
|
||||||
{@const path = row.rider ? riderPath(row.rider.id) : null}
|
{@const path = row.rider ? riderPath(row.rider.id) : null}
|
||||||
<Table.Row>
|
<Table.Row
|
||||||
|
class={path ? 'cursor-pointer' : undefined}
|
||||||
|
onclick={path ? () => goto(path) : undefined}
|
||||||
|
>
|
||||||
|
<!--
|
||||||
|
Ob jemand ein Konto hat, sagt ein
|
||||||
|
Schlüssel am Namen — keine eigene
|
||||||
|
Spalte.
|
||||||
|
|
||||||
|
Dort stand vorher in fast jeder Zeile
|
||||||
|
dasselbe „kann sich anmelden": eine
|
||||||
|
Spalte Breite für eine Angabe, die
|
||||||
|
meistens zutrifft und die niemand
|
||||||
|
vergleicht. Ein Zeichen genügt, und
|
||||||
|
sein Fehlen sagt den anderen Fall —
|
||||||
|
wer keins hat, dem bietet die
|
||||||
|
Teamleitung rechts „Konto anlegen" an.
|
||||||
|
-->
|
||||||
<Table.Cell class="font-medium">
|
<Table.Cell class="font-medium">
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
<Avatar user={row.userId ? teams.users[row.userId] : null} size={28} />
|
<Avatar user={row.userId ? teams.users[row.userId] : null} size={28} />
|
||||||
|
|
@ -564,35 +609,22 @@
|
||||||
{:else}
|
{:else}
|
||||||
{row.name}
|
{row.name}
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if row.userId}
|
||||||
|
<!-- Die Aktion braucht ein Element; auf einer
|
||||||
|
Komponente ist use: nicht erlaubt. -->
|
||||||
|
<span
|
||||||
|
class="inline-flex shrink-0"
|
||||||
|
use:tooltip={{ content: 'Hat ein Konto und kann sich anmelden' }}
|
||||||
|
>
|
||||||
|
<KeyRound class="size-3.5 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{#if !row.rider}
|
{#if !row.rider}
|
||||||
<Badge variant="outline">kein Fahrer</Badge>
|
<Badge variant="outline">kein Fahrer</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
<!--
|
|
||||||
Hier steht bewusst KEIN zweiter Name.
|
|
||||||
Der Name der Person steht am Fahrer,
|
|
||||||
der Kontoname wäre eine zweite
|
|
||||||
Wahrheit, die auseinanderläuft — und
|
|
||||||
die niemand geradeziehen könnte:
|
|
||||||
users.updateRule lässt nur den
|
|
||||||
Kontoinhaber selbst ändern, kein Admin
|
|
||||||
kommt an fremde Kontonamen heran. Das
|
|
||||||
Konto ist deshalb nur ein Ja oder
|
|
||||||
Nein: kann sich anmelden.
|
|
||||||
-->
|
|
||||||
<Table.Cell>
|
|
||||||
{#if row.userId}
|
|
||||||
<Badge variant="secondary" class="gap-1">
|
|
||||||
<UserCheck class="size-3" />
|
|
||||||
kann sich anmelden
|
|
||||||
</Badge>
|
|
||||||
{:else}
|
|
||||||
<span class="text-sm text-muted-foreground">kein Konto</span>
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{#if row.role === 'owner'}
|
{#if row.role === 'owner'}
|
||||||
<Badge class="gap-1">
|
<Badge class="gap-1">
|
||||||
|
|
@ -611,21 +643,13 @@
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell class="text-sm text-muted-foreground">
|
|
||||||
{row.rider ? participants.byRider(row.rider.id).length : '—'}
|
|
||||||
</Table.Cell>
|
|
||||||
|
|
||||||
<Table.Cell class="text-right text-sm text-muted-foreground">
|
|
||||||
{row.rider ? times.getByRider(row.rider.id).length : '—'}
|
|
||||||
</Table.Cell>
|
|
||||||
|
|
||||||
{#if canManage}
|
{#if canManage}
|
||||||
<Table.Cell class="text-right whitespace-nowrap">
|
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
|
||||||
{#if row.rider && !row.userId}
|
{#if row.rider && !row.userId}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
tooltip="Konto anlegen"
|
tooltip="Per E-Mail einladen"
|
||||||
onclick={() => openAccount(row.rider!)}
|
onclick={() => openAccount(row.rider!)}
|
||||||
>
|
>
|
||||||
<KeyRound class="size-4" />
|
<KeyRound class="size-4" />
|
||||||
|
|
@ -641,27 +665,26 @@
|
||||||
<Pencil class="size-4" />
|
<Pencil class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Ein einziger Eingriff an einer
|
||||||
|
fremden Person, und er nimmt
|
||||||
|
nichts weg, was jemand
|
||||||
|
gefahren ist. Der Owner ist
|
||||||
|
ausgenommen: Ohne ihn hätte
|
||||||
|
das Team niemanden mehr, der
|
||||||
|
es löschen oder übergeben
|
||||||
|
kann.
|
||||||
|
-->
|
||||||
{#if row.userId && row.role !== 'owner'}
|
{#if row.userId && row.role !== 'owner'}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
tooltip="Aus dem Team entfernen"
|
tooltip="Zugang zum Team entziehen"
|
||||||
onclick={() => removeFromTeam(row.userId!)}
|
onclick={() => removeFromTeam(row.userId!, row.name)}
|
||||||
>
|
>
|
||||||
<UserMinus class="size-4" />
|
<UserMinus class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if row.rider}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
tooltip="Fahrer löschen"
|
|
||||||
onclick={() => riders.remove(row.rider!)}
|
|
||||||
>
|
|
||||||
<Trash2 class="size-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
|
|
@ -675,15 +698,15 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Person anlegen oder bearbeiten -->
|
<!-- Fahrer anlegen oder bearbeiten -->
|
||||||
<Dialog.Root bind:open={personDialog}>
|
<Dialog.Root bind:open={riderDialog}>
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>{isEdit ? 'Person bearbeiten' : 'Person anlegen'}</Dialog.Title>
|
<Dialog.Title>{isEdit ? 'Fahrer bearbeiten' : 'Fahrer anlegen'}</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
{isEdit
|
{isEdit
|
||||||
? 'Der Name gilt teamweit; Startnummern hängen am jeweiligen Event.'
|
? 'Der Name gilt teamweit; Startnummern hängen am jeweiligen Event.'
|
||||||
: 'Kommt als Fahrer in den Kader. Ein Konto ist optional.'}
|
: 'Kommt als Fahrer in den Kader. Eine Einladung ist optional.'}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
|
|
@ -725,26 +748,29 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !isEdit}
|
{#if !isEdit}
|
||||||
|
<Separator />
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<label class="flex items-center gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={form.withAccount}
|
bind:checked={form.withAccount}
|
||||||
class="size-4 rounded border-input accent-primary"
|
class="size-4 rounded border-input accent-primary"
|
||||||
/>
|
/>
|
||||||
kann sich anmelden
|
gleich per E-Mail einladen
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{#if form.withAccount}
|
{#if form.withAccount}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="p-mail">E-Mail</Label>
|
<Label for="p-mail">E-Mail</Label>
|
||||||
<Input id="p-mail" type="email" bind:value={form.email} placeholder="max@example.com" />
|
<Input
|
||||||
</div>
|
id="p-mail"
|
||||||
<div class="space-y-2">
|
type="email"
|
||||||
<Label for="p-pw">Passwort</Label>
|
bind:value={form.email}
|
||||||
<Input id="p-pw" type="password" bind:value={form.password} placeholder="mindestens 8 Zeichen" />
|
placeholder="max@example.com"
|
||||||
|
/>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt
|
Der Fahrer bekommt einen Link und setzt sein Passwort selbst. Ohne
|
||||||
sich unter „Einstellungen" ändern.
|
Häkchen steht er nur im Kader — einladen lässt er sich jederzeit
|
||||||
|
später aus seiner Zeile.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -756,8 +782,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog.Footer>
|
<Dialog.Footer>
|
||||||
<Button variant="ghost" onclick={() => (personDialog = false)}>Abbrechen</Button>
|
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
|
||||||
<Button onclick={savePerson} disabled={busy}>
|
<Button onclick={saveRider} disabled={busy}>
|
||||||
{busy ? 'Wird gespeichert …' : editing ? 'Speichern' : 'Anlegen'}
|
{busy ? 'Wird gespeichert …' : editing ? 'Speichern' : 'Anlegen'}
|
||||||
</Button>
|
</Button>
|
||||||
</Dialog.Footer>
|
</Dialog.Footer>
|
||||||
|
|
@ -769,35 +795,64 @@
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>
|
<Dialog.Title>
|
||||||
Konto für {accountRider ? riderName(accountRider) : 'Fahrer'}
|
{accountRider ? riderName(accountRider) : 'Fahrer'} einladen
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
Mit einem Konto kann sich die Person selbst anmelden. Gib das Passwort
|
Es geht eine Mail mit einem Link raus. Das Passwort setzt der Fahrer
|
||||||
weiter; ändern lässt es sich unter „Einstellungen".
|
dabei selbst — du bekommst es nicht zu sehen und musst es nicht
|
||||||
|
weitergeben.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<div class="space-y-4">
|
{#if accountSent}
|
||||||
<div class="space-y-2">
|
<!-- Nach dem Versand steht hier nur noch, was passiert ist. Ein
|
||||||
<Label for="a-mail">E-Mail</Label>
|
zweites Mal abschicken ginge zwar, hieße aber, dieselbe Mail
|
||||||
<Input id="a-mail" type="email" bind:value={accountForm.email} placeholder="max@example.com" />
|
doppelt zu verschicken, ohne dass jemand danach gefragt hat. -->
|
||||||
</div>
|
<div class="space-y-2 py-2">
|
||||||
<div class="space-y-2">
|
<p class="text-sm">
|
||||||
<Label for="a-pw">Passwort</Label>
|
Einladung an <strong>{accountSent}</strong> ist unterwegs.
|
||||||
<Input id="a-pw" type="password" bind:value={accountForm.password} placeholder="mindestens 8 Zeichen" />
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Kommt nichts an, lohnt der Blick in den Spam-Ordner. Danach lässt sich
|
||||||
|
hier eine neue Einladung schicken — die alte verfällt damit.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if accountError}
|
<Dialog.Footer>
|
||||||
<p class="text-sm text-destructive">{accountError}</p>
|
<Button onclick={() => (accountDialog = false)}>Fertig</Button>
|
||||||
{/if}
|
</Dialog.Footer>
|
||||||
</div>
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="a-mail">E-Mail</Label>
|
||||||
|
<Input
|
||||||
|
id="a-mail"
|
||||||
|
type="email"
|
||||||
|
bind:value={accountForm.email}
|
||||||
|
placeholder="max@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Dialog.Footer>
|
<div class="space-y-2">
|
||||||
<Button variant="ghost" onclick={() => (accountDialog = false)}>Abbrechen</Button>
|
<Label for="a-days">Gültig für</Label>
|
||||||
<Button onclick={createAccount} disabled={accountBusy}>
|
<Input id="a-days" type="number" min="0" bind:value={accountForm.days} />
|
||||||
{accountBusy ? 'Wird angelegt …' : 'Konto anlegen'}
|
<p class="text-xs text-muted-foreground">
|
||||||
</Button>
|
Tage. 0 heißt: läuft nicht ab.
|
||||||
</Dialog.Footer>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if accountError}
|
||||||
|
<p class="text-sm text-destructive">{accountError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="ghost" onclick={() => (accountDialog = false)}>Abbrechen</Button>
|
||||||
|
<Button onclick={sendInvite} disabled={accountBusy}>
|
||||||
|
{accountBusy ? 'Wird verschickt …' : 'Einladung senden'}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
{/if}
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
|
|
@ -928,7 +983,7 @@
|
||||||
<Dialog.Title>Logo für „{logoTeam?.name}"</Dialog.Title>
|
<Dialog.Title>Logo für „{logoTeam?.name}"</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
Erscheint in der Kopfzeile und blass im Seitenhintergrund. Ohne eigenes
|
Erscheint in der Kopfzeile und blass im Seitenhintergrund. Ohne eigenes
|
||||||
Logo steht dort das Wappen des Stammtischs.
|
Logo steht dort die Kurbel.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,13 @@
|
||||||
{@const freshComments = comments.hasRecent(trail.id)}
|
{@const freshComments = comments.hasRecent(trail.id)}
|
||||||
<a href="/dashboard/trails/{trail.id}" class="block">
|
<a href="/dashboard/trails/{trail.id}" class="block">
|
||||||
<Card class="h-full hover:border-primary transition-colors">
|
<Card class="h-full hover:border-primary transition-colors">
|
||||||
<CardHeader class="pb-3">
|
<!--
|
||||||
|
Kein zusaetzliches pb: Die Karte setzt zwischen
|
||||||
|
Kopf und Inhalt schon ihr eigenes gap-4. Ein pb-3
|
||||||
|
obendrauf trieb Titel und Vorschaubild sichtbar
|
||||||
|
auseinander.
|
||||||
|
-->
|
||||||
|
<CardHeader>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<CardTitle class="text-base">{trail.name}</CardTitle>
|
<CardTitle class="text-base">{trail.name}</CardTitle>
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { auth, getFileURL } from '$lib/stores/pocketbase.svelte'
|
import { auth, getFileURL } from '$lib/stores/pocketbase.svelte'
|
||||||
import { publicInvite, type InviteTeam } from '$lib/stores/teamInvites.svelte'
|
import { publicInvite, type InviteTeam, type InviteRecipient } from '$lib/stores/teamInvites.svelte'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
@ -31,13 +31,31 @@
|
||||||
let team = $state<InviteTeam | null>(null)
|
let team = $state<InviteTeam | null>(null)
|
||||||
let loadError = $state<string | null>(null)
|
let loadError = $state<string | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Für wen der Link gilt. Ein Einladungslink gilt jedem — dann steht hier
|
||||||
|
* `personal: false` und das Formular ist leer wie bisher. Eine
|
||||||
|
* persönliche Einladung ging an genau eine Adresse; die steht schon drin
|
||||||
|
* und lässt sich nicht ändern.
|
||||||
|
*/
|
||||||
|
let recipient = $state<InviteRecipient | null>(null)
|
||||||
|
|
||||||
let form = $state({ name: '', email: '', password: '', passwordConfirm: '' })
|
let form = $state({ name: '', email: '', password: '', passwordConfirm: '' })
|
||||||
let busy = $state(false)
|
let busy = $state(false)
|
||||||
let error = $state<string | null>(null)
|
let error = $state<string | null>(null)
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
team = await publicInvite.load(token)
|
const data = await publicInvite.load(token)
|
||||||
|
team = data.team
|
||||||
|
recipient = data.recipient
|
||||||
|
|
||||||
|
// Was der Server über den Eingeladenen weiß, muss er nicht noch
|
||||||
|
// einmal eintippen. Der Name steht im Kader, die Adresse hat die
|
||||||
|
// Mail erreicht.
|
||||||
|
if (data.recipient.personal) {
|
||||||
|
form.email = data.recipient.email
|
||||||
|
if (data.recipient.name) form.name = data.recipient.name
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
loadError = e.message ?? 'Diese Einladung gibt es nicht mehr.'
|
loadError = e.message ?? 'Diese Einladung gibt es nicht mehr.'
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -90,7 +108,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Einladung</title></svelte:head>
|
<svelte:head><title>Einladung · Kurbeler</title></svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center bg-background p-4">
|
<div class="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
<Card class="w-full max-w-md">
|
<Card class="w-full max-w-md">
|
||||||
|
|
@ -123,6 +141,9 @@
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{#if auth.user}
|
{#if auth.user}
|
||||||
Du bist als {auth.user.name || auth.user.email} angemeldet.
|
Du bist als {auth.user.name || auth.user.email} angemeldet.
|
||||||
|
{:else if recipient?.personal}
|
||||||
|
Such dir ein Passwort aus — mehr fehlt nicht. Als Fahrer stehst
|
||||||
|
du schon im Kader.
|
||||||
{:else}
|
{:else}
|
||||||
Leg dir ein Konto an — danach stehst du als Fahrer im Kader.
|
Leg dir ein Konto an — danach stehst du als Fahrer im Kader.
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -163,15 +184,28 @@
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="i-mail">E-Mail</Label>
|
<Label for="i-mail">E-Mail</Label>
|
||||||
|
<!--
|
||||||
|
Bei einer persönlichen Einladung steht die Adresse
|
||||||
|
fest: An sie ging die Mail, und nur für sie gilt der
|
||||||
|
Token. Das Feld ist deshalb gesperrt — dass ein
|
||||||
|
gesperrtes Feld nur eine Bitte ist, weiß der Hook und
|
||||||
|
prüft es beim Anlegen noch einmal.
|
||||||
|
-->
|
||||||
<Input
|
<Input
|
||||||
id="i-mail"
|
id="i-mail"
|
||||||
type="email"
|
type="email"
|
||||||
bind:value={form.email}
|
bind:value={form.email}
|
||||||
disabled={busy}
|
disabled={busy || !!recipient?.personal}
|
||||||
|
readonly={!!recipient?.personal}
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
placeholder="max@example.com"
|
placeholder="max@example.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
{#if recipient?.personal}
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
An diese Adresse ging die Einladung.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
import { auth } from '$lib/stores/pocketbase.svelte'
|
import { auth } from '$lib/stores/pocketbase.svelte'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import Spinner from '$lib/components/Spinner.svelte'
|
import Spinner from '$lib/components/Spinner.svelte'
|
||||||
import Logo from '$lib/components/Logo.svelte'
|
import Wordmark from '$lib/components/Wordmark.svelte'
|
||||||
|
|
||||||
let email = $state('')
|
let email = $state('')
|
||||||
let password = $state('')
|
let password = $state('')
|
||||||
|
|
@ -35,12 +35,12 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Login</title></svelte:head>
|
<svelte:head><title>Kurbeler</title></svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center bg-background p-4">
|
<div class="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
<Card class="w-full max-w-md">
|
<Card class="w-full max-w-md">
|
||||||
<CardHeader class="justify-items-center gap-3 pt-2 text-center">
|
<CardHeader class="justify-items-center gap-3 pt-2 text-center">
|
||||||
<Logo class="h-20 w-20" />
|
<CardTitle><Wordmark class="h-11" /></CardTitle>
|
||||||
<CardDescription>Melde dich an, um fortzufahren</CardDescription>
|
<CardDescription>Melde dich an, um fortzufahren</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
|
||||||
BIN
frontend/static/kurbeler-icon-192.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
frontend/static/kurbeler-icon-512.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
1
frontend/static/kurbeler-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
frontend/static/kurbeler-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
frontend/static/kurbeler-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
frontend/static/kurbeler-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
frontend/static/kurbeler-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 |
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "Stammtisch Hersbruck",
|
"name": "Kurbeler",
|
||||||
"short_name": "Stammtisch",
|
"short_name": "Kurbeler",
|
||||||
"description": "Events, Stages, Zeiten und Trails des Stammtisch Hersbruck",
|
"description": "Events, Stages, Zeiten und Trails",
|
||||||
"start_url": "/dashboard",
|
"start_url": "/dashboard",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
|
@ -11,10 +11,28 @@
|
||||||
"lang": "de",
|
"lang": "de",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/schildl.svg",
|
"src": "/kurbeler-icon.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/kurbeler-icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/kurbeler-icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/kurbeler-icon-maskable.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 55 KiB |
1
frontend/static/stammtisch-hersbruck.svg
Normal file
|
After Width: | Height: | Size: 47 KiB |