diff --git a/CLAUDE.md b/CLAUDE.md index abca6e6..15a5857 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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/`. @@ -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` - **Collections**: users, teams, team_invites, events, stages, riders, times, 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 `@request.auth.id != "" && (…)`. Ohne diesen Vorspann ist eine *leere* Relation gleich dem leeren `@request.auth.id` einer anonymen Anfrage — ein Team ohne Admins stünde damit offen im Netz. Siehe `backend/pb_migrations/1754501700_rules_require_login.js`. - **Hooks**: `backend/pb_hooks/` enthält serverseitiges JS für das, was keine - Collection-Regel abbilden kann — derzeit die öffentlichen Routen der - Einladungslinks. Jeder Route-Handler läuft in einer eigenen JS-Laufzeit; - gemeinsamer Code gehört in ein Modul und wird per + Collection-Regel abbilden kann — die öffentlichen Routen der Einladungslinks, + den Uhrenabgleich, `GET /api/directory/users` (Benutzerverzeichnis des + Superadmins), `POST /api/team-invites/send` (Einladungsmail) und die + Benachrichtigungen. Jeder Route- und Event-Handler läuft in einer eigenen + JS-Laufzeit; **was neben `routerAdd`/`onRecord…` im Dateiscope steht, sieht + der Handler nicht.** Gemeinsamer Code gehört in ein Modul und wird per ``require(`${__hooks}/…`)`` im Handler geholt. Details in `backend/README.md`. +- **Mails**: SMTP im Admin-UI; dazu `APP_URL` (Adresse des **Frontends**, für + die Links) und optional `APP_NAME`. Vorlagen liegen in + `backend/pb_hooks/views/` und werden mit `$template.loadFiles(…).render(…)` + gefüllt. +- **Benachrichtigungen**: Kein Auslöser verschickt eine Mail — alle legen + einen Eintrag in `notifications`, ein Cron-Job macht daraus **eine + Zusammenfassung pro Konto und Tag** (6 Uhr). Voreinstellungen stehen doppelt, + in `notifications_lib.js` und `notificationPrefs.svelte.ts`; beide Listen + müssen zusammenpassen. - **Authentication**: Handled through `src/lib/stores/pocketbase.svelte.ts` with the `AuthStore` class - **File handling**: Use `getFileURL(record, file, options)` helper for PocketBase file URLs @@ -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 Stelle: Kennzahlen, Teamdaten, Einladungslinks und die Fahrerliste samt der 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 - (`riders.createLogin`), es gibt kein Verknüpfen bestehender Konten und kein - Lösen. Angezeigt wird ausschließlich der Fahrername — der Kontoname wäre + — die Teamleitung lädt per Mail ein (`POST /api/team-invites/send`), das + Passwort setzt der Eingeladene selbst. Ein Passwort im Kader zu vergeben gibt + es nicht mehr. Verknüpft wird der Fahrer beim Einlösen der Einladung; es gibt + kein Verknüpfen bestehender Konten und kein Lösen. Angezeigt wird ausschließlich der Fahrername — der Kontoname wäre eine zweite Wahrheit, die niemand geradeziehen könnte, weil `users.updateRule` nur den Kontoinhaber selbst ändern lässt. - Reusable components in `src/lib/components/` diff --git a/NAMING.md b/NAMING.md deleted file mode 100644 index faf414f..0000000 --- a/NAMING.md +++ /dev/null @@ -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. diff --git a/README.md b/README.md index 15baa44..1c0b6b2 100644 --- a/README.md +++ b/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. +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: | Verzeichnis | Inhalt | diff --git a/backend/.env.example b/backend/.env.example index a1eb8ab..2339aab 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -26,3 +26,18 @@ SUPERUSER_PASSWORD=bitte-aendern-min-8-zeichen # Optional: überschreibt den in docker-compose.yaml gepinnten Default. # Nur setzen, wenn bewusst eine andere PocketBase-Version gebaut werden soll. #PB_VERSION=0.39.11 + +# Adresse des Frontends, ohne Schrägstrich am Ende. Aus ihr baut PocketBase +# die Links in Einladungs- und Benachrichtigungsmails. +# +# Wichtig: Das ist NICHT die Adresse von PocketBase. Ein Einladungslink auf +# die API führte ins Leere — er muss auf die Seite zeigen, die das Konto +# anlegt. Ohne diese Variable verweigern die Mail-Routen den Versand, statt +# tote Links zu verschicken. +# +# Lokal: http://stammtisch-hersbruck.de.localhost:31337 +APP_URL=https://stammtisch-hersbruck.de + +# Optional: Name der Anwendung in Betreff und Mailtext. Ohne die Variable +# steht dort "Kurbeler". Nur setzen, wenn eine Instanz sich abheben soll. +#APP_NAME=Kurbeler (Test) diff --git a/backend/README.md b/backend/README.md index e1a1519..e361cf2 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,4 +1,4 @@ -# stammtisch-hersbruck.de — Backend (PocketBase) +# Kurbeler — Backend (PocketBase) PocketBase-Instanz für Events, Läufe, Fahrer, Zeiten und Teams. @@ -105,12 +105,121 @@ Serverseitiges JavaScript, das PocketBase beim Start lädt (`--hooksDir`). Wie `pb_migrations/` kommt das Verzeichnis per `COPY` ins Image — eine Änderung wirkt also erst nach `docker compose up -d --build`. -Bisher liegt dort ein Anliegen: die öffentlichen Routen der Einladungslinks. - | Datei | Zweck | |---|---| +| `clock.pb.js` | Uhrenabgleich für die Zeitnahme | | `team_invites.pb.js` | `GET /api/team-invites/{token}`, `POST …/accept`, `POST …/join` | -| `team_invites_lib.js` | Gemeinsame Helfer der drei Routen | +| `team_invites_lib.js` | Gemeinsame Helfer der drei Einladungs-Routen | +| `directory.pb.js` | `GET /api/directory/users` — Benutzerverzeichnis des Superadmins | +| `directory_lib.js` | Helfer dazu | +| `mail_lib.js` | Absender, Vorlagen, Versand — alles, was Mails verschickt, geht hier durch | +| `notifications.pb.js` | Auslöser der Benachrichtigungen und der tägliche Versand | +| `notifications_lib.js` | Voreinstellungen, Empfängerkreis, Warteschlange | +| `views/invite.html` | Vorlage der Einladungsmail | +| `views/digest.html` | Vorlage der Tageszusammenfassung | + +Vorlagen werden mit `$template.loadFiles(...).render(data)` gefüllt — Go's +`html/template`. Die Platzhalter stehen deshalb in `{{ }}` und werden dabei +escaped; Trailnamen und Kommentartexte kommen aus der Datenbank und dürfen +kein Markup einschleusen. + +## Mails + +SMTP wird im Admin-UI eingerichtet (Settings → Mail settings). Zwei +Umgebungsvariablen kommen dazu, beide in `.env.example` beschrieben: + +| Variable | Wofür | +|---|---| +| `APP_URL` | Adresse des **Frontends**. Aus ihr entstehen die Links in Mails. | +| `APP_NAME` | Optional. Name in Betreff und Text; ohne sie steht dort „Kurbeler". | + +`APP_URL` ist ausdrücklich nicht `settings().meta.appURL` — die zeigt auf +PocketBase selbst, und ein Einladungslink dorthin führte auf die API statt auf +die Seite, die das Konto anlegt. Sie ist auch nicht der `Origin` des +Aufrufers: Der steht unter dessen Kontrolle, und ein Link in einer Mail, den +ein Fremder auf seinen eigenen Server zeigen lassen kann, ist eine +Phishing-Vorlage mit unserem Absender darauf. Fehlt die Variable, verweigern +die Mail-Routen den Dienst. + +### Einladung statt Passwort + +`POST /api/team-invites/send` ist der Ersatz für „Konto anlegen" im Kader. +Dort tippte die Teamleitung Adresse **und Passwort** ein und musste dieses +Passwort anschließend irgendwie zum Fahrer bringen — über einen Kanal, den +niemand geplant hat. Sie kannte es außerdem. + +Jetzt legt die Route eine persönliche Einladung an (`team_invites` mit +`email` und `rider`) und verschickt sie. Das Passwort entsteht erst beim +Empfänger auf `/invite/{token}`. Die Adresse ist dabei gebunden: Das Feld auf +der Seite ist gesperrt, und weil ein gesperrtes Feld nur eine Bitte ist, prüft +`/accept` sie noch einmal. + +Die allgemeinen **Einladungslinks bleiben unverändert** — sie gelten jedem, +der sie hat, und lassen `email` und `rider` leer. + +### Benachrichtigungen: eine Mail am Tag + +Kein Auslöser verschickt eine Mail. Alle legen einen Eintrag in +`notifications`, und `cronAdd('notifications_digest', '0 6 * * *', …)` macht +daraus einmal täglich je Konto eine Zusammenfassung. + +Das ist der ganze Punkt: Ein Trailkommentar, dem drei Antworten folgen, löste +sonst vier Mails aus. Ein Verein, der sich die nicht leisten kann, schaltet +nach drei Tagen alles ab. + +Nebenwirkung, die zählt: Ein Fehler im Mailversand kann nie das Speichern +eines Events verhindern — der Auslöser schreibt nur in eine Tabelle. +Scheitert der Versand an ein Konto, bleiben dessen Einträge unversendet stehen +und sind am nächsten Morgen wieder dabei. + +Was ohne Einstellungen gilt, steht als `DEFAULTS` in `notifications_lib.js` +und noch einmal in `frontend/src/lib/stores/notificationPrefs.svelte.ts`. +**Beide Listen müssen zusammenpassen** — stünde dort etwas anderes, zeigte die +Oberfläche einen Schalter an, der nichts mit dem zu tun hat, was verschickt +wird. Die Linie: An heißt „betrifft mich", aus heißt „ist viel". + +`notifications` ist über die API nicht beschreibbar (create/update/delete auf +`null`, in PocketBase „nur Superuser"). Gefüllt wird sie ausschließlich aus +den Hooks, die an den Regeln vorbeilaufen — sonst schriebe sich jeder selbst +Meldungen, oder anderen. + +### Warum das Verzeichnis eine Route braucht + +Konten entstehen mit `emailVisibility: false` (`teams.svelte.ts`, +`createMember`). PocketBase gibt die E-Mail-Adresse damit nur ihrem Inhaber +heraus — in jedem `users`-Datensatz, den jemand anderes liest, ist das Feld +leer. Ein Verzeichnis ohne Adressen wäre aber eine Liste von Namen, in der +niemand ein Konto wiedererkennt. + +Das Flag umzulegen wäre eine Zeile und zugleich ein Loch: `users.listRule` +gilt appweit (`@request.auth.id != ""`), jede angemeldete Person läse danach +jede Adresse — quer über alle Teams. Der Hook liest sie stattdessen +serverseitig und gibt sie nur dem heraus, der sie sehen darf. + +`superadmin` wird dabei frisch aus der Datenbank gelesen und nicht aus dem +mitgeschickten Auth-Record: Der Token hält den Stand von der Anmeldung fest, +eine entzogene Rolle wirkte sonst bis zu seinem Ablauf nicht. + +## Wer darf ein Konto löschen? + +Zwei, und sonst niemand — `users.deleteRule` (siehe +`1757340000_users_superadmin_delete.js`): + +| Wer | Wo | +|---|---| +| Der Inhaber selbst | Einstellungen → Profil | +| Superadmin | Einstellungen → Benutzer | + +Die Teamleitung ausdrücklich **nicht**. Sie verwaltet Fahrer und +Mitgliedschaften; im Kader gibt es dafür genau einen Eingriff an einer fremden +Person, „Zugang zum Team entziehen" (`teams.removeMember`). Der nimmt das Konto +aus `teams.users`/`teams.admins` — Fahrer, Teilnahmen und Zeiten bleiben +stehen. Sie sind die Geschichte des Teams und nicht die des Logins. + +Ein Konto, das ein Team besitzt, lässt sich nicht löschen: `teams.owner` ist +eine Pflichtrelation ohne cascadeDelete, PocketBase müsste sie beim Löschen +leeren und bräche dabei den Datensatz. Beide Oberflächen prüfen das vorher und +sagen, welches Team im Weg steht — sonst stünde dort ein Datenbankfehler. Warum überhaupt ein Hook? Wer über einen Link beitritt, ist noch nicht angemeldet und muss drei Dinge auf einmal tun, die keine Collection-Regel diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml index f00598a..ddd8529 100644 --- a/backend/docker-compose.yaml +++ b/backend/docker-compose.yaml @@ -29,6 +29,15 @@ services: # Superuser wird nur beim allerersten Start angelegt (siehe entrypoint.sh) SUPERUSER_EMAIL: ${SUPERUSER_EMAIL:-} SUPERUSER_PASSWORD: ${SUPERUSER_PASSWORD:-} + # Adresse des Frontends. Aus ihr entstehen die Links in Mails — + # Einladung und Tageszusammenfassung. NICHT die Adresse von PocketBase: + # Ein Link dorthin führte auf die API statt auf die Seite. Fehlt die + # Variable, verweigern die Mail-Routen den Dienst, statt tote Links zu + # verschicken. + APP_URL: ${APP_URL:-} + # Name der Anwendung in Betreff und Mailtext. Nur setzen, wenn eine + # Instanz sich abheben soll ("Kurbeler (Test)"). + APP_NAME: ${APP_NAME:-} healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8090/api/health"] interval: 30s diff --git a/backend/pb_hooks/directory.pb.js b/backend/pb_hooks/directory.pb.js new file mode 100644 index 0000000..fc99094 --- /dev/null +++ b/backend/pb_hooks/directory.pb.js @@ -0,0 +1,74 @@ +/// + +// 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()) diff --git a/backend/pb_hooks/directory_lib.js b/backend/pb_hooks/directory_lib.js new file mode 100644 index 0000000..43716e3 --- /dev/null +++ b/backend/pb_hooks/directory_lib.js @@ -0,0 +1,78 @@ +/// + +// 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, +} diff --git a/backend/pb_hooks/mail_lib.js b/backend/pb_hooks/mail_lib.js new file mode 100644 index 0000000..9c38f9e --- /dev/null +++ b/backend/pb_hooks/mail_lib.js @@ -0,0 +1,112 @@ +/// + +// 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, +} diff --git a/backend/pb_hooks/notifications.pb.js b/backend/pb_hooks/notifications.pb.js new file mode 100644 index 0000000..d130ec8 --- /dev/null +++ b/backend/pb_hooks/notifications.pb.js @@ -0,0 +1,326 @@ +/// + +// 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) + } + } +}) diff --git a/backend/pb_hooks/notifications_lib.js b/backend/pb_hooks/notifications_lib.js new file mode 100644 index 0000000..e7de34d --- /dev/null +++ b/backend/pb_hooks/notifications_lib.js @@ -0,0 +1,221 @@ +/// + +// 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, +} diff --git a/backend/pb_hooks/team_invites.pb.js b/backend/pb_hooks/team_invites.pb.js index 6d30f32..fa6b9df 100644 --- a/backend/pb_hooks/team_invites.pb.js +++ b/backend/pb_hooks/team_invites.pb.js @@ -38,7 +38,10 @@ routerAdd('GET', '/api/team-invites/{token}', (e) => { throw new NotFoundError('Diese Einladung gibt es nicht mehr.') } - return e.json(200, { team: lib.teamPayload(e.app, invite) }) + return e.json(200, { + team: lib.teamPayload(e.app, invite), + recipient: lib.recipientPayload(e.app, invite), + }) }) // --- Konto anlegen und beitreten ------------------------------------------- @@ -80,6 +83,17 @@ routerAdd('POST', '/api/team-invites/{token}/accept', (e) => { throw new BadRequestError('Diese Einladung gilt nicht mehr.') } + // Eine persönliche Einladung ging an genau eine Adresse. Sie hier + // erneut zu prüfen ist der Punkt, an dem es zählt: Das Feld auf der + // Seite ist gesperrt, aber ein gesperrtes Feld ist eine Bitte und + // keine Bedingung — die Anfrage kann jeder von Hand stellen. + const invitedEmail = invite.getString('email') + if (invitedEmail && invitedEmail.toLowerCase() !== email.toLowerCase()) { + throw new BadRequestError( + 'Diese Einladung gilt für eine andere E-Mail-Adresse.', + ) + } + // Ohne die Vorabprüfung meldete PocketBase nur „Value must be unique". // Der Hinweis auf den zweiten Weg ist hier mehr wert als die // Fehlermeldung. @@ -143,3 +157,172 @@ routerAdd('POST', '/api/team-invites/{token}/join', (e) => { return e.json(200, { ok: true, team: result.teamId }) }, $apis.requireAuth()) + +// --- Persönliche Einladung: Konto per Mail statt Passwort per Zuruf -------- +// +// Der Ersatz für „Konto anlegen" im Kader. Dort tippte die Teamleitung eine +// Adresse und ein Passwort ein und musste dieses Passwort anschließend +// irgendwie zum Fahrer bringen — über einen Kanal, den niemand geplant hat. +// Sie kannte es außerdem, und der Fahrer hatte eins, das er sich nicht +// ausgesucht hat. +// +// Jetzt geht eine Mail raus, und das Passwort entsteht erst beim Empfänger. +// +// Warum eine Route und kein Collection-Create mit anschließendem Versand? +// Weil beides zusammengehört: Eine Einladung, die niemanden erreicht, ist +// keine — sie stünde nur als Datensatz herum und die Teamleitung wartete auf +// jemanden, der nie eine Mail bekommen hat. Hier scheitert beides zusammen +// oder gar nichts. +// +// POST /api/team-invites/send { rider, email, days? } + +routerAdd('POST', '/api/team-invites/send', (e) => { + const lib = require(`${__hooks}/team_invites_lib.js`) + const mail = require(`${__hooks}/mail_lib.js`) + + const user = e.auth + if (!user) { + throw new UnauthorizedError() + } + + const base = mail.appURL() + if (!base) { + // Ohne Adresse des Frontends gibt es keinen Link, und ohne Link ist + // die Mail wertlos. Das hier ist ein Konfigurationsfehler und gehört + // sichtbar gemeldet, nicht stillschweigend umschifft. + throw new InternalServerError( + 'APP_URL ist nicht gesetzt — ohne sie lässt sich kein Einladungslink bilden.', + ) + } + + const data = new DynamicModel({ rider: '', email: '', days: 14 }) + e.bindBody(data) + + const email = (data.email || '').trim() + if (email.indexOf('@') < 1) { + throw new BadRequestError('Bitte eine gültige E-Mail-Adresse angeben.') + } + + let rider + try { + rider = e.app.findRecordById('riders', (data.rider || '').trim()) + } catch (err) { + throw new NotFoundError('Diesen Fahrer gibt es nicht.') + } + + if (rider.getString('user')) { + throw new BadRequestError('Dieser Fahrer hat schon ein Konto.') + } + + const team = e.app.findRecordById('teams', rider.getString('team')) + + // Einladen darf die Teamleitung — dieselbe Bedingung wie in der + // createRule von team_invites, hier von Hand geprüft, weil die Route an + // den Collection-Regeln vorbeiläuft. + let allowed = false + try { + e.app.findFirstRecordByFilter( + 'teams', + 'id = {:team} && (owner.id ?= {:user} || admins.id ?= {:user})', + { team: team.id, user: user.id }, + ) + allowed = true + } catch (err) { + allowed = false + } + + if (!allowed) { + throw new ForbiddenError('Einladen darf nur die Teamleitung.') + } + + // Ohne die Vorabprüfung liefe der Eingeladene erst beim Anlegen des + // Kontos in „Value must be unique" — nach der Mail, nach dem Klick, am + // Ende eines Weges, den er umsonst gegangen wäre. + let existing = null + try { + existing = e.app.findAuthRecordByEmail('users', email) + } catch (err) { + existing = null + } + + if (existing) { + throw new BadRequestError( + 'Zu dieser Adresse gibt es schon ein Konto. Es kann über einen ' + + 'Einladungslink ins Team kommen.', + ) + } + + const days = parseInt(data.days, 10) + let expires = '' + if (days > 0) { + const until = new Date(Date.now() + days * 24 * 60 * 60 * 1000) + expires = until.toISOString().replace('T', ' ').replace('Z', 'Z') + } + + let invite + e.app.runInTransaction((txApp) => { + // Eine zweite Einladung an denselben Fahrer ersetzt die erste. Sonst + // stapelten sich bei jedem „nochmal senden" Datensätze, von denen + // jeder für sich gültig bliebe — und ein zurückgezogener wäre keiner. + try { + const old = txApp.findFirstRecordByFilter( + 'team_invites', + 'rider = {:rider}', + { rider: rider.id }, + ) + txApp.delete(old) + } catch (err) { + // Keine vorhandene — der Normalfall. + } + + invite = new Record(txApp.findCollectionByNameOrId('team_invites')) + invite.set('team', team.id) + invite.set('rider', rider.id) + invite.set('email', email) + invite.set('created_by', user.id) + invite.set('max_uses', 1) + invite.set('uses', 0) + if (expires) { + invite.set('expires', expires) + } + txApp.save(invite) + }) + + const name = rider.getString('name') || 'Radlfahrer' + const teamName = team.getString('name') || mail.appName() + + // Der Versand steht bewusst NACH der Transaktion: Ein SMTP-Aufruf gehört + // nicht in eine offene Datenbanktransaktion, er dauert und kann hängen. + // Scheitert er, verschwindet die Einladung wieder — eine, von der niemand + // weiß, wäre schlimmer als keine. + try { + mail.sendView( + e.app, + { address: email, name: name }, + `${teamName} lädt dich zu ${mail.appName()} ein`, + 'invite.html', + { + App: mail.appName(), + Team: teamName, + Name: name, + Link: `${base}/invite/${invite.getString('token')}`, + Expires: mail.formatDay(expires), + }, + ) + } catch (err) { + try { + e.app.delete(invite) + } catch (cleanupErr) { + // Aufräumen ist der Versuch wert, aber nicht der Fehler, den der + // Aufrufer sehen soll. + } + throw new InternalServerError( + 'Die Einladung konnte nicht verschickt werden. Stimmen die SMTP-Einstellungen?', + ) + } + + invite.set('sent', new Date().toISOString().replace('T', ' ')) + e.app.save(invite) + + return e.json(200, { ok: true, email: email, expires: expires }) +}, $apis.requireAuth()) diff --git a/backend/pb_hooks/team_invites_lib.js b/backend/pb_hooks/team_invites_lib.js index ee9e104..d5a2145 100644 --- a/backend/pb_hooks/team_invites_lib.js +++ b/backend/pb_hooks/team_invites_lib.js @@ -56,6 +56,35 @@ function teamPayload(app, invite) { } } +/** + * Wem gilt die Einladung? Beim Einladungslink: niemandem Bestimmten. + * + * Eine persönliche Einladung nennt Adresse und Namen. Die Seite füllt beides + * vor und lässt die Adresse nicht ändern — sonst legte jemand mit fremdem + * Link ein Konto auf eine Adresse an, an die nie eine Mail ging. + * + * Das ist keine Preisgabe: Wer den Token hat, hat die Mail bekommen, in der + * die Adresse ohnehin steht. + */ +function recipientPayload(app, invite) { + const email = invite.getString('email') + if (!email) { + return { email: '', name: '', personal: false } + } + + let name = '' + const riderId = invite.getString('rider') + if (riderId) { + try { + name = app.findRecordById('riders', riderId).getString('name') + } catch (err) { + name = '' + } + } + + return { email: email, name: name, personal: true } +} + /** * Trägt einen bestehenden Nutzer ins Team ein und legt ihm einen Fahrer an, * falls er dort noch keinen hat. Zählt die Einladung hoch. @@ -100,6 +129,29 @@ function joinTeam(txApp, invite, user, name) { rider = null } + // Eine persönliche Einladung nennt den Fahrer, um den es geht: Sie ging + // an jemanden, der längst im Kader steht und nur noch kein Konto hat. + // Ohne diesen Zweig entstünde daneben ein zweiter Fahrer gleichen Namens + // — dieselbe Person doppelt, einmal mit Zeiten und einmal mit Login. + if (!rider) { + const invitedRiderId = invite.getString('rider') + if (invitedRiderId) { + try { + const invited = txApp.findRecordById('riders', invitedRiderId) + // Nur, wenn er noch frei ist. Hat er inzwischen ein Konto, + // gehört er jemand anderem; dann ist ein eigener Fahrer für + // den Eingeladenen richtig. + if (invited.getString('team') === team.id && !invited.getString('user')) { + invited.set('user', user.id) + txApp.save(invited) + rider = invited + } + } catch (err) { + rider = null + } + } + } + if (!rider) { rider = new Record(txApp.findCollectionByNameOrId('riders')) rider.set('team', team.id) @@ -119,4 +171,4 @@ function joinTeam(txApp, invite, user, name) { return { teamId: team.id, riderId: rider.id, joined: !isMember } } -module.exports = { findValidInvite, teamPayload, joinTeam } +module.exports = { findValidInvite, teamPayload, recipientPayload, joinTeam } diff --git a/backend/pb_hooks/views/digest.html b/backend/pb_hooks/views/digest.html new file mode 100644 index 0000000..170fd5e --- /dev/null +++ b/backend/pb_hooks/views/digest.html @@ -0,0 +1,94 @@ + + + + + + + {{.App}} — Zusammenfassung + + +
+ {{.Count}} Neuigkeiten aus deinen Teams. +
+ + + + + +
+ + + + + + {{range .Groups}} + + + + {{end}} + + + + +
+

+ {{.App}} +

+

+ Was seit gestern passiert ist +

+

+ Hallo {{.Name}} — {{.Count}} Neuigkeit{{if ne .Count 1}}en{{end}}. +

+
+

+ {{.Label}} +

+ + {{range .Items}} + + + + +
+

+ {{if .Link}}{{.Title}}{{else}}{{.Title}}{{end}} +

+ {{if .Body}} +

+ {{.Body}} +

+ {{end}} +
+ {{end}} +
+
+

+ Zu viel oder zu wenig? Unter + Einstellungen → Benachrichtigungen + stellst du ein, worüber diese Mail berichtet — oder schaltest sie ganz ab. +

+
+ +

{{.App}}

+
+ + diff --git a/backend/pb_hooks/views/invite.html b/backend/pb_hooks/views/invite.html new file mode 100644 index 0000000..1d4c9ea --- /dev/null +++ b/backend/pb_hooks/views/invite.html @@ -0,0 +1,116 @@ + + + + + + + Einladung zu {{.Team}} + + + +
+ {{.Team}} lädt dich zu {{.App}} ein. Passwort setzt du selbst. +
+ + + + + +
+ + + + + + + + + + + + + + + + + + {{if .Expires}} + + + + {{end}} + + + + +
+

+ {{.App}} +

+

+ {{.Team}} lädt dich ein +

+
+

Hallo {{.Name}},

+

+ du stehst im Kader von {{.Team}}. Mit einem eigenen + Konto siehst du Termine, trägst dich zu Events ein und findest deine + Zeiten wieder. +

+

+ Über den Knopf legst du das Konto selbst an — dein Passwort + wählst du dabei selbst, niemand sonst kennt es. +

+
+ + + + + +
+ + Konto anlegen + +
+
+

+ Falls der Knopf nicht geht, diese Adresse in den Browser kopieren: +

+

+ {{.Link}} +

+
+ Die Einladung gilt bis zum {{.Expires}}. +
+
+

+ Du erwartest keine Einladung? Dann ignorier diese Mail einfach — + ohne den Link entsteht kein Konto. +

+
+ +

{{.App}}

+
+ + diff --git a/backend/pb_migrations/1754400000_init_schema.js b/backend/pb_migrations/1754400000_init_schema.js index 3c4d8c9..171a238 100644 --- a/backend/pb_migrations/1754400000_init_schema.js +++ b/backend/pb_migrations/1754400000_init_schema.js @@ -2144,6 +2144,47 @@ migrate((app) => { "system": false, "type": "relation" }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "email_invite_email", + "max": 0, + "min": 0, + "name": "email", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "spgs7e4anufen9u", + "help": "", + "hidden": false, + "id": "relation_invite_rider", + "maxSelect": 1, + "minSelect": 0, + "name": "rider", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "date_invite_sent", + "max": "", + "min": "", + "name": "sent", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, { "help": "", "hidden": false, @@ -2208,6 +2249,307 @@ migrate((app) => { ], "system": false } + , + { + "id": "pbc_notification_prefs", + "listRule": "@request.auth.id != \"\" && user = @request.auth.id", + "viewRule": "@request.auth.id != \"\" && user = @request.auth.id", + "createRule": "@request.auth.id != \"\" && @request.body.user = @request.auth.id", + "updateRule": "@request.auth.id != \"\" && user = @request.auth.id", + "deleteRule": "@request.auth.id != \"\" && user = @request.auth.id", + "name": "notification_prefs", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation_prefs_user", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "bool_prefs_enabled", + "name": "enabled", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool_prefs_event_created", + "name": "event_created", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool_prefs_event_participation", + "name": "event_participation", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool_prefs_event_changed", + "name": "event_changed", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool_prefs_trail_comment", + "name": "trail_comment", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool_prefs_trail_status", + "name": "trail_status", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool_prefs_trail_marker", + "name": "trail_marker", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool_prefs_team_member", + "name": "team_member", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "autodate_created", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "help": "", + "hidden": false, + "id": "autodate_updated", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_notification_prefs_user` ON `notification_prefs` (`user`)" + ], + "system": false + }, + { + "id": "pbc_notifications", + "listRule": "@request.auth.id != \"\" && user = @request.auth.id", + "viewRule": "@request.auth.id != \"\" && user = @request.auth.id", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "notifications", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation_notification_user", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": true, + "collectionId": "pbc_1568971955", + "help": "", + "hidden": false, + "id": "relation_notification_team", + "maxSelect": 1, + "minSelect": 0, + "name": "team", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text_notification_type", + "max": 0, + "min": 0, + "name": "type", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text_notification_title", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text_notification_body", + "max": 0, + "min": 0, + "name": "body", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text_notification_link", + "max": 0, + "min": 0, + "name": "link", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "date_notification_sent", + "max": "", + "min": "", + "name": "sent", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "help": "", + "hidden": false, + "id": "autodate_created", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "help": "", + "hidden": false, + "id": "autodate_updated", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE INDEX `idx_notifications_pending` ON `notifications` (`user`, `sent`)" + ], + "system": false + } ] // importCollections erwartet das Array selbst, keinen JSON-String — die diff --git a/backend/pb_migrations/1757340000_users_superadmin_delete.js b/backend/pb_migrations/1757340000_users_superadmin_delete.js new file mode 100644 index 0000000..422650d --- /dev/null +++ b/backend/pb_migrations/1757340000_users_superadmin_delete.js @@ -0,0 +1,38 @@ +/// + +// 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) +}) diff --git a/backend/pb_migrations/1757600000_invites_personal.js b/backend/pb_migrations/1757600000_invites_personal.js new file mode 100644 index 0000000..b15e161 --- /dev/null +++ b/backend/pb_migrations/1757600000_invites_personal.js @@ -0,0 +1,83 @@ +/// + +// 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) +}) diff --git a/backend/pb_migrations/1757700000_notifications.js b/backend/pb_migrations/1757700000_notifications.js new file mode 100644 index 0000000..de290a7 --- /dev/null +++ b/backend/pb_migrations/1757700000_notifications.js @@ -0,0 +1,158 @@ +/// + +// 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. + } +}) diff --git a/design/logo/README.md b/design/logo/README.md new file mode 100644 index 0000000..43f2652 --- /dev/null +++ b/design/logo/README.md @@ -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. diff --git a/design/logo/build.py b/design/logo/build.py new file mode 100644 index 0000000..3bf9c53 --- /dev/null +++ b/design/logo/build.py @@ -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'' 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'{body}') + 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'') + 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'{body}\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'' + f''), WORD_BOX["w"] * s + + files = {} + files["mark.svg"] = svg(64, 64, + f'') + # Die Kachel: Marke auf 84 %, damit sie nicht an die Rundung stoesst. + files["icon.svg"] = svg(64, 64, + f'' + f'') + files["icon-dark.svg"] = svg(64, 64, + f'' + f'') + # Randlos fuer Android: 62 %, alles ausserhalb kann beschnitten werden. + files["icon-maskable.svg"] = svg(64, 64, + f'' + f'') + + 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'{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) diff --git a/design/logo/garnitur/icon-192.png b/design/logo/garnitur/icon-192.png new file mode 100644 index 0000000..ec6551d Binary files /dev/null and b/design/logo/garnitur/icon-192.png differ diff --git a/design/logo/garnitur/icon-512.png b/design/logo/garnitur/icon-512.png new file mode 100644 index 0000000..af0c99c Binary files /dev/null and b/design/logo/garnitur/icon-512.png differ diff --git a/design/logo/garnitur/icon-dark.svg b/design/logo/garnitur/icon-dark.svg new file mode 100644 index 0000000..fe56075 --- /dev/null +++ b/design/logo/garnitur/icon-dark.svg @@ -0,0 +1 @@ + diff --git a/design/logo/garnitur/icon-maskable.svg b/design/logo/garnitur/icon-maskable.svg new file mode 100644 index 0000000..6f50b4c --- /dev/null +++ b/design/logo/garnitur/icon-maskable.svg @@ -0,0 +1 @@ + diff --git a/design/logo/garnitur/icon.svg b/design/logo/garnitur/icon.svg new file mode 100644 index 0000000..a962edc --- /dev/null +++ b/design/logo/garnitur/icon.svg @@ -0,0 +1 @@ + diff --git a/design/logo/garnitur/logo-dark.svg b/design/logo/garnitur/logo-dark.svg new file mode 100644 index 0000000..a3e5f65 --- /dev/null +++ b/design/logo/garnitur/logo-dark.svg @@ -0,0 +1 @@ + diff --git a/design/logo/garnitur/logo-mono.svg b/design/logo/garnitur/logo-mono.svg new file mode 100644 index 0000000..4992c0a --- /dev/null +++ b/design/logo/garnitur/logo-mono.svg @@ -0,0 +1 @@ + diff --git a/design/logo/garnitur/logo.svg b/design/logo/garnitur/logo.svg new file mode 100644 index 0000000..752ec85 --- /dev/null +++ b/design/logo/garnitur/logo.svg @@ -0,0 +1 @@ + diff --git a/design/logo/garnitur/mark.svg b/design/logo/garnitur/mark.svg new file mode 100644 index 0000000..bd3dedd --- /dev/null +++ b/design/logo/garnitur/mark.svg @@ -0,0 +1 @@ + diff --git a/design/logo/geometry.json b/design/logo/geometry.json new file mode 100644 index 0000000..a4940c8 --- /dev/null +++ b/design/logo/geometry.json @@ -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 + ] + } +} \ No newline at end of file diff --git a/design/logo/kurbel-k/icon-192.png b/design/logo/kurbel-k/icon-192.png new file mode 100644 index 0000000..646d84d Binary files /dev/null and b/design/logo/kurbel-k/icon-192.png differ diff --git a/design/logo/kurbel-k/icon-512.png b/design/logo/kurbel-k/icon-512.png new file mode 100644 index 0000000..d994d94 Binary files /dev/null and b/design/logo/kurbel-k/icon-512.png differ diff --git a/design/logo/kurbel-k/icon-dark.svg b/design/logo/kurbel-k/icon-dark.svg new file mode 100644 index 0000000..629e73a --- /dev/null +++ b/design/logo/kurbel-k/icon-dark.svg @@ -0,0 +1 @@ + diff --git a/design/logo/kurbel-k/icon-maskable.svg b/design/logo/kurbel-k/icon-maskable.svg new file mode 100644 index 0000000..ab224b2 --- /dev/null +++ b/design/logo/kurbel-k/icon-maskable.svg @@ -0,0 +1 @@ + diff --git a/design/logo/kurbel-k/icon.svg b/design/logo/kurbel-k/icon.svg new file mode 100644 index 0000000..a1652fa --- /dev/null +++ b/design/logo/kurbel-k/icon.svg @@ -0,0 +1 @@ + diff --git a/design/logo/kurbel-k/logo-dark.svg b/design/logo/kurbel-k/logo-dark.svg new file mode 100644 index 0000000..f2f4357 --- /dev/null +++ b/design/logo/kurbel-k/logo-dark.svg @@ -0,0 +1 @@ + diff --git a/design/logo/kurbel-k/logo-mono.svg b/design/logo/kurbel-k/logo-mono.svg new file mode 100644 index 0000000..d39635e --- /dev/null +++ b/design/logo/kurbel-k/logo-mono.svg @@ -0,0 +1 @@ + diff --git a/design/logo/kurbel-k/logo.svg b/design/logo/kurbel-k/logo.svg new file mode 100644 index 0000000..cb217fc --- /dev/null +++ b/design/logo/kurbel-k/logo.svg @@ -0,0 +1 @@ + diff --git a/design/logo/kurbel-k/mark.svg b/design/logo/kurbel-k/mark.svg new file mode 100644 index 0000000..cf6e3b6 --- /dev/null +++ b/design/logo/kurbel-k/mark.svg @@ -0,0 +1 @@ + diff --git a/design/logo/kurbelarm/icon-192.png b/design/logo/kurbelarm/icon-192.png new file mode 100644 index 0000000..4805b0d Binary files /dev/null and b/design/logo/kurbelarm/icon-192.png differ diff --git a/design/logo/kurbelarm/icon-512.png b/design/logo/kurbelarm/icon-512.png new file mode 100644 index 0000000..5b9490b Binary files /dev/null and b/design/logo/kurbelarm/icon-512.png differ diff --git a/design/logo/kurbelarm/icon-dark.svg b/design/logo/kurbelarm/icon-dark.svg new file mode 100644 index 0000000..9b63381 --- /dev/null +++ b/design/logo/kurbelarm/icon-dark.svg @@ -0,0 +1 @@ + diff --git a/design/logo/kurbelarm/icon-maskable.svg b/design/logo/kurbelarm/icon-maskable.svg new file mode 100644 index 0000000..dcdb5b3 --- /dev/null +++ b/design/logo/kurbelarm/icon-maskable.svg @@ -0,0 +1 @@ + diff --git a/design/logo/kurbelarm/icon.svg b/design/logo/kurbelarm/icon.svg new file mode 100644 index 0000000..a3872d6 --- /dev/null +++ b/design/logo/kurbelarm/icon.svg @@ -0,0 +1 @@ + diff --git a/design/logo/kurbelarm/logo-dark.svg b/design/logo/kurbelarm/logo-dark.svg new file mode 100644 index 0000000..7f068b5 --- /dev/null +++ b/design/logo/kurbelarm/logo-dark.svg @@ -0,0 +1 @@ + diff --git a/design/logo/kurbelarm/logo-mono.svg b/design/logo/kurbelarm/logo-mono.svg new file mode 100644 index 0000000..d6ac1aa --- /dev/null +++ b/design/logo/kurbelarm/logo-mono.svg @@ -0,0 +1 @@ + diff --git a/design/logo/kurbelarm/logo.svg b/design/logo/kurbelarm/logo.svg new file mode 100644 index 0000000..3e0809b --- /dev/null +++ b/design/logo/kurbelarm/logo.svg @@ -0,0 +1 @@ + diff --git a/design/logo/kurbelarm/mark.svg b/design/logo/kurbelarm/mark.svg new file mode 100644 index 0000000..b43f2bf --- /dev/null +++ b/design/logo/kurbelarm/mark.svg @@ -0,0 +1 @@ + diff --git a/design/logo/word.d b/design/logo/word.d new file mode 100644 index 0000000..8bcbe5f --- /dev/null +++ b/design/logo/word.d @@ -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 \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index 1c80b6c..32ecc76 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,4 +1,4 @@ -# stammtisch-hersbruck.de — Frontend +# Kurbeler — Frontend SvelteKit-5-Anwendung (Svelte 5 Runes, Tailwind 4) für Zeitnahme und Verwaltung. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0846f82..3414331 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,13 +1,15 @@ { - "name": "stammtisch-hersbruck.de", - "version": "2.0.0-alpha", + "name": "kurbeler", + "version": "2.0.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "stammtisch-hersbruck.de", - "version": "2.0.0-alpha", + "name": "kurbeler", + "version": "2.0.0-beta", "dependencies": { + "@fontsource-variable/inter": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "date-fns": "^4.1.0", "dompurify": "^3.4.13", "lucide-svelte": "^0.503.0", @@ -735,6 +737,24 @@ "dev": true, "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": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8b768e0..59da023 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { - "name": "stammtisch-hersbruck.de", + "name": "kurbeler", "private": true, - "version": "2.0.0-alpha", + "version": "2.0.0-beta", "type": "module", "engines": { "node": "^20.19 || ^22.12 || >=24" @@ -41,6 +41,8 @@ "vitest": "^4.1.10" }, "dependencies": { + "@fontsource-variable/inter": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "date-fns": "^4.1.0", "dompurify": "^3.4.13", "lucide-svelte": "^0.503.0", diff --git a/frontend/src/app.css b/frontend/src/app.css index a87ca12..ba942b1 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,59 +1,92 @@ @import "tailwindcss"; @import "tw-animate-css"; +/* + * Schriften liegen im Bundle, nicht bei Google: Ein Aufruf an fonts.gstatic.com + * schickt die IP jedes Besuchers an einen Dritten, und dafuer bräuchte es eine + * Einwilligung, die niemand geben will, um eine Zeit zu stoppen. + * + * Inter als variable Schrift — ein Datei je Subset statt einer je Schnitt, + * dafuer jedes Gewicht zwischen 100 und 900. IBM Plex Mono in genau zwei + * Schnitten, mehr braucht es fuer Zahlen und Kleinschriften nicht. + */ +@import "@fontsource-variable/inter/wght.css"; +@import "@fontsource/ibm-plex-mono/latin-400.css"; +@import "@fontsource/ibm-plex-mono/latin-600.css"; + @custom-variant dark (&:is(.dark *)); -/* shadcn theme: yellow (oklch) */ +/* + * Die Farbwelt der Werkstatt: warme Neutrale statt des kuehlen Zinc-Grau, + * das shadcn mitbringt. Alle Grautoene stehen auf Farbton 92 mit sehr wenig + * Buntheit — sie sind nicht grau, sondern *sehr blass gelb*, dieselbe Familie + * wie der Akzent. Nebeneinander wirkt das ruhig statt zusammengewuerfelt. + * + * Der Akzent bleibt unveraendert: #eab308 ist die Themenfarbe der App, die + * Farbe des Logos und die des Installationssymbols. + * + * Der Grund ist bewusst nicht Weiss. Karten sind es — dadurch heben sie sich + * ohne Rahmen und ohne Schatten ab, und der Schatten steht wieder fuer das, + * wofuer er da ist: etwas, das ueber der Seite schwebt. + */ :root { - --radius: 0.65rem; - --background: oklch(1 0 0); - --foreground: oklch(0.141 0.005 285.823); - --card: oklch(1 0 0); - --card-foreground: oklch(0.141 0.005 285.823); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.141 0.005 285.823); + --radius: 0.5rem; + --background: oklch(0.973 0.005 92); + --foreground: oklch(0.19 0.008 88); + --card: oklch(0.995 0.002 92); + --card-foreground: oklch(0.19 0.008 88); + --popover: oklch(0.995 0.002 92); + --popover-foreground: oklch(0.19 0.008 88); --primary: oklch(0.795 0.184 86.047); --primary-foreground: oklch(0.421 0.095 57.708); - --secondary: oklch(0.967 0.001 286.375); - --secondary-foreground: oklch(0.21 0.006 285.885); - --muted: oklch(0.967 0.001 286.375); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent: oklch(0.967 0.001 286.375); - --accent-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.951 0.007 92); + --secondary-foreground: oklch(0.24 0.008 88); + --muted: oklch(0.951 0.007 92); + --muted-foreground: oklch(0.535 0.014 88); + --accent: oklch(0.951 0.007 92); + --accent-foreground: oklch(0.24 0.008 88); --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); + --border: oklch(0.898 0.011 90); + --input: oklch(0.898 0.011 90); --ring: oklch(0.795 0.184 86.047); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar: oklch(0.985 0.004 92); + --sidebar-foreground: oklch(0.19 0.008 88); --sidebar-primary: oklch(0.795 0.184 86.047); --sidebar-primary-foreground: oklch(0.421 0.095 57.708); - --sidebar-accent: oklch(0.967 0.001 286.375); - --sidebar-accent-foreground: oklch(0.21 0.006 285.885); - --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-accent: oklch(0.951 0.007 92); + --sidebar-accent-foreground: oklch(0.24 0.008 88); + --sidebar-border: oklch(0.898 0.011 90); --sidebar-ring: oklch(0.795 0.184 86.047); } +/* + * Dunkel ist hier nicht Schwarz, sondern angeraeuchertes Holz — derselbe + * Farbton 90, nur unten in der Helligkeit. Reines Schwarz laesst das Gelb + * grell aussehen; auf dem warmen Grund sitzt es ruhig. + * + * Raender sind halbdurchsichtiges Weiss und keine eigene Farbe: So bleiben + * sie auf Karte und Grund gleich sichtbar, ohne dass es zwei Tokens braucht. + */ .dark { - --background: oklch(0.141 0.005 285.823); - --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); - --popover-foreground: oklch(0.985 0 0); + --background: oklch(0.158 0.006 90); + --foreground: oklch(0.968 0.005 92); + --card: oklch(0.215 0.007 90); + --card-foreground: oklch(0.968 0.005 92); + --popover: oklch(0.215 0.007 90); + --popover-foreground: oklch(0.968 0.005 92); --primary: oklch(0.852 0.199 91.936); --primary-foreground: oklch(0.421 0.095 57.708); - --secondary: oklch(0.274 0.006 286.033); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); - --accent-foreground: oklch(0.985 0 0); + --secondary: oklch(0.272 0.008 90); + --secondary-foreground: oklch(0.968 0.005 92); + --muted: oklch(0.272 0.008 90); + --muted-foreground: oklch(0.702 0.014 90); + --accent: oklch(0.272 0.008 90); + --accent-foreground: oklch(0.968 0.005 92); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); @@ -63,17 +96,33 @@ --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.21 0.006 285.885); - --sidebar-foreground: oklch(0.985 0 0); + --sidebar: oklch(0.215 0.007 90); + --sidebar-foreground: oklch(0.968 0.005 92); --sidebar-primary: oklch(0.852 0.199 91.936); --sidebar-primary-foreground: oklch(0.421 0.095 57.708); - --sidebar-accent: oklch(0.274 0.006 286.033); - --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.272 0.008 90); + --sidebar-accent-foreground: oklch(0.968 0.005 92); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.554 0.135 66.442); } @theme inline { + /* + * Zwei Schriften mit klarer Arbeitsteilung: Inter traegt alles, was man + * liest — Ueberschriften, Saetze, Knoepfe. Plex Mono traegt alles, was + * man *abliest* — Zeiten, Startnummern, Koordinaten, Kennzahlen. Der + * Wechsel ist damit nicht Geschmack, sondern eine Aussage darueber, was + * fuer eine Art Zahl da steht. + */ + --font-sans: 'Inter Variable', 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + + /* + * Radien nach Rolle, nicht nach Geschmack: Bedienelemente sind klein und + * sitzen dicht, sie bekommen wenig (sm/md); Flaechen, auf denen etwas + * steht, bekommen mehr (lg/xl). Vorher lag ein einziger Wert von 0,65rem + * auf allem, vom Knopf bis zur Seite. + */ --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -117,6 +166,30 @@ } body { @apply bg-background text-foreground; + font-family: var(--font-sans); + /* + * Inters echtes Einzelstockwerk-a und -g (`cv11`) und der geschlitzte + * Null (`ss01`) — an einer Strecke wird nebenbei gelesen, da hilft + * jeder Buchstabe, der nur einer Sache aehnlich sieht. + */ + font-feature-settings: 'cv11', 'ss01'; + } + + /* + * Ueberschriften stehen eng: Inter laeuft von Haus aus grosszuegig, was + * bei 24 px und mehr auseinanderfaellt. Der Wert sinkt mit steigender + * Groesse, so wie es eine Schrift mit optischen Groessen selbst taete. + */ + h1, h2, h3 { + letter-spacing: -0.018em; + } + + /* + * Ziffern in Tabellen und Listen stehen untereinander — dieselbe Breite + * fuer jede Ziffer, sonst wandert die Zehntelsekunde von Zeile zu Zeile. + */ + table, .tabular { + font-variant-numeric: tabular-nums; } /* @@ -148,15 +221,43 @@ @layer components { /* - * Startnummern wie am Trikot: schmal, fett, ohne Zierrat. Impact ist auf - * Windows und macOS da, Haettenschweiler und Arial Narrow Bold springen - * ein; am Ende steht eine beliebige serifenlose Schrift, dann sieht die - * Nummer eben normal aus statt falsch. + * Startnummern. Vorher stand hier Impact mit vier Rueckfallschriften — + * eine Wette darauf, was auf dem Geraet installiert ist, die auf Android + * und Linux verloren ging. Jetzt traegt sie Plex Mono, die ohnehin im + * Bundle liegt: jede Ziffer gleich breit, damit eine 11 nicht schmaler + * aussieht als eine 44, und weit gesperrt, damit sie auf Abstand gelesen + * wird statt als Zahl ueberflogen. */ .font-startnumber { - font-family: Impact, Haettenschweiler, 'Arial Narrow Bold', 'Franklin Gothic Bold', sans-serif; - font-weight: 700; - font-stretch: condensed; - letter-spacing: 0.02em; + font-family: var(--font-mono); + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: 0.06em; + } + + /* + * Die Kleinschrift des Hauses: Kapitaelchen-Ersatz fuer alles, was ein + * Feld benennt statt es zu fuellen — Spaltenkoepfe, Abschnittsmarken, + * Beschriftungen ueber Kennzahlen. Gesperrt und klein, damit sie neben + * dem Wert zuruecktritt, aber Mono, damit sie als System erkennbar ist. + */ + .label-mono { + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + line-height: 1.4; + color: var(--muted-foreground); + } + + /* + * Zeiten und Messwerte. Mono und tabellarisch, damit Zahlen spaltenweise + * stehen; leicht zusammengezogen, weil Plex Mono sonst breit baut. + */ + .numeric { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; } } diff --git a/frontend/src/lib/brand.ts b/frontend/src/lib/brand.ts new file mode 100644 index 0000000..580aa23 --- /dev/null +++ b/frontend/src/lib/brand.ts @@ -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, +} diff --git a/frontend/src/lib/components/Avatar.svelte b/frontend/src/lib/components/Avatar.svelte index 459b9bd..92e328b 100644 --- a/frontend/src/lib/components/Avatar.svelte +++ b/frontend/src/lib/components/Avatar.svelte @@ -15,7 +15,20 @@ size = 32, 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 class?: string } = $props() diff --git a/frontend/src/lib/components/Icon.svelte b/frontend/src/lib/components/Icon.svelte new file mode 100644 index 0000000..13441e6 --- /dev/null +++ b/frontend/src/lib/components/Icon.svelte @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/lib/components/Logo.svelte b/frontend/src/lib/components/Logo.svelte index a72bb9a..43e1b6a 100644 --- a/frontend/src/lib/components/Logo.svelte +++ b/frontend/src/lib/components/Logo.svelte @@ -1,28 +1,36 @@ - +{#if url} + +{:else if icon} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/Mark.svelte b/frontend/src/lib/components/Mark.svelte new file mode 100644 index 0000000..2fbcb9f --- /dev/null +++ b/frontend/src/lib/components/Mark.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/Wordmark.svelte b/frontend/src/lib/components/Wordmark.svelte new file mode 100644 index 0000000..9a723a2 --- /dev/null +++ b/frontend/src/lib/components/Wordmark.svelte @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte index 46e930b..f2e433d 100644 --- a/frontend/src/lib/components/ui/button/button.svelte +++ b/frontend/src/lib/components/ui/button/button.svelte @@ -4,7 +4,7 @@ import { type VariantProps, tv } from "tailwind-variants"; export const buttonVariants = tv({ - base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-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: { variant: { default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", diff --git a/frontend/src/lib/components/ui/dialog/dialog-content.svelte b/frontend/src/lib/components/ui/dialog/dialog-content.svelte index c0551ee..a8cb906 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-content.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-content.svelte @@ -28,7 +28,7 @@ bind:ref data-slot="dialog-content" 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 )} {...restProps} diff --git a/frontend/src/lib/components/ui/input/input.svelte b/frontend/src/lib/components/ui/input/input.svelte index fe7db38..ac8b1a3 100644 --- a/frontend/src/lib/components/ui/input/input.svelte +++ b/frontend/src/lib/components/ui/input/input.svelte @@ -25,7 +25,7 @@ bind:this={ref} data-slot={dataSlot} 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 )} type="file" @@ -38,7 +38,7 @@ bind:this={ref} data-slot={dataSlot} 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 )} {type} diff --git a/frontend/src/lib/components/ui/select/select-trigger.svelte b/frontend/src/lib/components/ui/select/select-trigger.svelte index 03d06d0..fea0ec1 100644 --- a/frontend/src/lib/components/ui/select/select-trigger.svelte +++ b/frontend/src/lib/components/ui/select/select-trigger.svelte @@ -19,7 +19,7 @@ data-slot="select-trigger" data-size={size} 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 )} {...restProps} diff --git a/frontend/src/lib/components/ui/table/table-head.svelte b/frontend/src/lib/components/ui/table/table-head.svelte index 267c4e0..2e71a88 100644 --- a/frontend/src/lib/components/ui/table/table-head.svelte +++ b/frontend/src/lib/components/ui/table/table-head.svelte @@ -10,6 +10,12 @@ }: WithElementRef = $props(); - + + {@render children?.()} diff --git a/frontend/src/lib/components/ui/table/table-header.svelte b/frontend/src/lib/components/ui/table/table-header.svelte index 72c02fc..d45b147 100644 --- a/frontend/src/lib/components/ui/table/table-header.svelte +++ b/frontend/src/lib/components/ui/table/table-header.svelte @@ -13,7 +13,7 @@ {@render children?.()} diff --git a/frontend/src/lib/stores/notificationPrefs.svelte.ts b/frontend/src/lib/stores/notificationPrefs.svelte.ts new file mode 100644 index 0000000..5064e18 --- /dev/null +++ b/frontend/src/lib/stores/notificationPrefs.svelte.ts @@ -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 & { 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(null) + values = $state({ ...DEFAULTS }) + loading = $state(false) + saving = $state(false) + error = $state(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() diff --git a/frontend/src/lib/stores/riders.svelte.ts b/frontend/src/lib/stores/riders.svelte.ts index 714b22b..d5776bb 100644 --- a/frontend/src/lib/stores/riders.svelte.ts +++ b/frontend/src/lib/stores/riders.svelte.ts @@ -1,6 +1,5 @@ import { getContext, setContext } from 'svelte' import { api } from './pocketbase.svelte' -import { app } from './app.svelte' import { getTeamContext } from './teams.svelte' import type { RidersResponse, UsersResponse } from '$lib/types' @@ -92,62 +91,22 @@ export class RiderStore { return await api.collection('riders').update(id, data) } - /** - * Fahrer löschen — und mit ihm die Mitgliedschaft seines Kontos. - * - * Ohne den zweiten Schritt riss jedes Löschen ein Loch: Der Fahrer war - * fort, sein Konto blieb in `teams.users` und stand danach als Zeile ohne - * Fahrer im Kader. Aus so einer Zeile führte kein Weg zurück — ein Konto - * nachträglich an einen Fahrer zu hängen gibt es bewusst nicht, weil - * dieselbe Person sonst zweimal in der Liste stehen könnte. Der Eintrag - * war damit unreparierbar. - * - * Das Konto selbst bleibt bestehen: Löschen darf es allein sein Inhaber - * (`users.deleteRule = id = @request.auth.id`), und über einen neuen - * Einladungslink kommt es zurück ins Team. - * - * Der Owner ist ausgenommen. Ihn aus `teams.users` zu nehmen, nähme ihm - * die Sicht auf sein eigenes Team — die listRule der meisten Collections - * 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((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() - }, - }) - }) - } + // Ein „Fahrer löschen" gibt es nicht mehr. + // + // Im Kader stand bis hierher ein Papierkorb neben „Aus dem Team + // entfernen". Was er wirklich tat, war aus zwei Icons nicht abzulesen: + // Er löschte den Fahrer samt Teilnahmen, nahm nebenbei das Konto aus dem + // Team — und ließ die Zeiten stehen, obwohl der Rückfragetext das + // Gegenteil versprach. `times.rider` hat cascadeDelete: false; die + // Zeit-Datensätze überlebten mit leerem Fahrer und standen danach + // namenlos in der Stage-Rangliste, die nach Stage filtert und nicht nach + // Fahrer. + // + // Geblieben ist der eine Eingriff, der niemandem etwas wegnimmt: + // `teams.removeMember` entzieht den Zugang zum Team. Fahrer, Teilnahmen + // und Zeiten bleiben — sie sind die Geschichte des Teams und nicht die + // des Logins. Konten löscht allein ihr Inhaber oder der Superadmin im + // Benutzerverzeichnis unter Einstellungen. getById(id: string) { return this.records.find((r) => r.id === id) @@ -158,33 +117,19 @@ export class RiderStore { return this.scoped.filter((r) => r.user === userId) } - /** - * 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 - * stünde dieselbe Person zweimal in der Liste, einmal mit und einmal ohne - * Login. - */ - private async linkUser(riderId: string, userId: string) { - return await this.edit(riderId, { user: userId } as Partial) - } - - /** - * Legt dem Fahrer ein Login an und verknüpft es. Das Konto entsteht im - * Team-Store — es ist zuerst ein Teammitglied und erst dann dieser Fahrer. - */ - async createLogin( - rider: RidersResponse, - data: { email: string; password: string; name?: string }, - ): Promise { - const user = await this.teams.createMember({ - ...data, - name: data.name?.trim() || riderName(rider), - }) - - await this.linkUser(rider.id, user.id) - - return user - } + // Ein Konto legt der Kader nicht mehr an. + // + // Hier standen `linkUser` und `createLogin`: Die Teamleitung dachte sich + // eine Adresse und ein Passwort aus, das Konto entstand sofort — und sie + // kannte danach das Passwort eines anderen Menschen und musste es + // irgendwie zu ihm bringen. + // + // 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 + // Einladung. Die Richtung bleibt dieselbe wie zuvor — ein Konto entsteht + // am Fahrer, gelöst wird die Verbindung nie. get filtered() { const q = this.searchQuery.toLowerCase().trim() diff --git a/frontend/src/lib/stores/teamInvites.svelte.ts b/frontend/src/lib/stores/teamInvites.svelte.ts index 7009060..ae605db 100644 --- a/frontend/src/lib/stores/teamInvites.svelte.ts +++ b/frontend/src/lib/stores/teamInvites.svelte.ts @@ -113,6 +113,29 @@ export class TeamInviteStore { 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 * verloren gehen kann — wer einen braucht, erzeugt den nächsten. @@ -161,11 +184,31 @@ async function send(path: string, body?: unknown) { 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 = { /** Zu welchem Team gehört der Link? Wirft, wenn er nicht (mehr) gilt. */ - async load(token: string): Promise { + async load(token: string): Promise<{ team: InviteTeam; recipient: InviteRecipient }> { 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. */ diff --git a/frontend/src/lib/stores/teams.svelte.ts b/frontend/src/lib/stores/teams.svelte.ts index 4807784..7289a74 100644 --- a/frontend/src/lib/stores/teams.svelte.ts +++ b/frontend/src/lib/stores/teams.svelte.ts @@ -219,32 +219,11 @@ export class TeamStore { return u.name || u.email || id.slice(0, 6) + '…' } - /** - * Legt ein Login an und nimmt es ins Team auf. Zwei Schritte, weil die - * REST-API keine Transaktion kennt: Scheitert die Aufnahme, existiert das - * Konto bereits — der Aufrufer meldet den Fehler, statt still - * weiterzumachen. - * - * 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 - } + // `createMember` ist entfallen. Es legte ein Konto mitsamt Passwort an — + // gebraucht wurde es allein von `riders.createLogin`, und das gibt es + // nicht mehr. Konten entstehen jetzt ausschließlich dort, wo auch das + // Passwort entsteht: beim Empfänger einer Einladung, in den Routen aus + // backend/pb_hooks/team_invites.pb.js. destroy() { if (this.unsubscribe) { diff --git a/frontend/src/lib/types.d.ts b/frontend/src/lib/types.d.ts index 3dfdf10..9c24dee 100644 --- a/frontend/src/lib/types.d.ts +++ b/frontend/src/lib/types.d.ts @@ -9,6 +9,8 @@ export enum Collections { EventParticipants = "event_participants", EventSeries = "event_series", Events = "events", + NotificationPrefs = "notification_prefs", + Notifications = "notifications", Riders = "riders", Stages = "stages", TeamInvites = "team_invites", @@ -114,6 +116,34 @@ export type EventsRecord = { 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 = { avatar?: string created?: IsoDateString @@ -136,9 +166,12 @@ export type StagesRecord = { export type TeamInvitesRecord = { created?: IsoDateString created_by?: RecordIdString + email?: string expires?: IsoDateString id: string max_uses?: number + rider?: RecordIdString + sent?: IsoDateString team: RecordIdString token: string updated?: IsoDateString @@ -277,6 +310,8 @@ export type UsersRecord = { export type EventParticipantsResponse = Required & BaseSystemFields export type EventSeriesResponse = Required & BaseSystemFields export type EventsResponse = Required & BaseSystemFields +export type NotificationPrefsResponse = Required & BaseSystemFields +export type NotificationsResponse = Required & BaseSystemFields export type RidersResponse = Required & BaseSystemFields export type StagesResponse = Required & BaseSystemFields export type TeamInvitesResponse = Required & BaseSystemFields @@ -295,6 +330,8 @@ export type CollectionRecords = { event_participants: EventParticipantsRecord event_series: EventSeriesRecord events: EventsRecord + notification_prefs: NotificationPrefsRecord + notifications: NotificationsRecord riders: RidersRecord stages: StagesRecord team_invites: TeamInvitesRecord @@ -312,6 +349,8 @@ export type CollectionResponses = { event_participants: EventParticipantsResponse event_series: EventSeriesResponse events: EventsResponse + notification_prefs: NotificationPrefsResponse + notifications: NotificationsResponse riders: RidersResponse stages: StagesResponse team_invites: TeamInvitesResponse @@ -332,6 +371,8 @@ export type TypedPocketBase = PocketBase & { collection(idOrName: 'event_participants'): RecordService collection(idOrName: 'event_series'): RecordService collection(idOrName: 'events'): RecordService + collection(idOrName: 'notification_prefs'): RecordService + collection(idOrName: 'notifications'): RecordService collection(idOrName: 'riders'): RecordService collection(idOrName: 'stages'): RecordService collection(idOrName: 'team_invites'): RecordService diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 98a1e6b..e862bde 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,6 +1,5 @@ - + + diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index e7d652e..a14a1b7 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -199,7 +199,7 @@ onmouseenter={() => (dashHover = true)} onmouseleave={() => (dashHover = false)} > - + + {#snippet stat(label: string, value: number, Icon: typeof Calendar, hint: string)} + + + {label} + + + +
{value}
+

{hint}

+
+
+ {/snippet} +
- - - Events - - - -
{stats.totalEvents}
-

{stats.activeEvents} heute

-
-
- - - - Fahrer - - - -
{stats.totalRiders}
-

registriert

-
-
- - - - Laufende Zeiten - - - -
{stats.running}
-

{stats.finished} abgeschlossen

-
-
+ {@render stat('Events', stats.totalEvents, Calendar, `${stats.activeEvents} heute`)} + {@render stat('Fahrer', stats.totalRiders, Users, 'registriert')} + {@render stat('Laufende Zeiten', stats.running, Clock, `${stats.finished} abgeschlossen`)}
diff --git a/frontend/src/routes/dashboard/events/+page.svelte b/frontend/src/routes/dashboard/events/+page.svelte index 909416b..fdfefab 100644 --- a/frontend/src/routes/dashboard/events/+page.svelte +++ b/frontend/src/routes/dashboard/events/+page.svelte @@ -203,7 +203,7 @@ ]) -Events – Zeitnahme +Events · Kurbeler
diff --git a/frontend/src/routes/dashboard/events/[id]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/+page.svelte index 716bff2..526c35d 100644 --- a/frontend/src/routes/dashboard/events/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/events/[id]/+page.svelte @@ -49,6 +49,23 @@ const eventParticipants = $derived(event ? participants.byEvent(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 — * dieselbe Regel wie in der Collection. @@ -254,7 +271,7 @@ } -{event?.name ?? 'Event'} – Zeitnahme +{event?.name ?? 'Event'} · Kurbeler {#if !event} @@ -376,7 +393,6 @@ {#each eventParticipants as p (p.id)} {@const rider = riders.getById(p.rider)} - {@const done = times.getByRider(p.rider).filter((t) => t.status === 'finished').length} goto(riderPath(p.rider))}> {p.number || '—'} @@ -404,7 +420,11 @@ {#if event.timing} - {done} von {eventStages.length} Stages + {#if eventStages.length === 0} + — + {:else} + {stagesDone(p.rider)} von {eventStages.length} Stages + {/if} {/if} e.stopPropagation()}> diff --git a/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte index e21cbd9..13a23b2 100644 --- a/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte +++ b/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte @@ -85,7 +85,7 @@ }) -{rider ? riderName(rider) : 'Fahrer'} – {event?.name ?? 'Event'} +{rider ? riderName(rider) : 'Fahrer'} – {event?.name ?? 'Event'} · Kurbeler {#if !valid} diff --git a/frontend/src/routes/dashboard/events/[id]/stages/[stageId]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/stages/[stageId]/+page.svelte index f6e03e6..cafa38b 100644 --- a/frontend/src/routes/dashboard/events/[id]/stages/[stageId]/+page.svelte +++ b/frontend/src/routes/dashboard/events/[id]/stages/[stageId]/+page.svelte @@ -162,7 +162,7 @@ } -{stage?.description ?? 'Stage'} – {event?.name ?? 'Event'} +{stage?.description ?? 'Stage'} – {event?.name ?? 'Event'} · Kurbeler {#if !valid} diff --git a/frontend/src/routes/dashboard/riders/[riderId]/+page.svelte b/frontend/src/routes/dashboard/riders/[riderId]/+page.svelte new file mode 100644 index 0000000..56b76c7 --- /dev/null +++ b/frontend/src/routes/dashboard/riders/[riderId]/+page.svelte @@ -0,0 +1,280 @@ + + +{rider ? riderName(rider) || 'Fahrer' : 'Fahrer'} · Kurbeler + +{#if !rider} + + + +

Fahrer nicht gefunden

+

+ Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht. +

+ +
+
+{:else} +
+
+ + + Team + +
+ +

+ {riderName(rider) || 'Ohne Name'} +

+ + {#if role === 'owner'} + Owner + {:else if role === 'admin'} + Admin + {:else if role === 'mitglied'} + Mitglied + {/if} + + {#if rider.user} + + + kann sich anmelden + + {:else} + kein Konto + {/if} +
+
+ +
+ + + Events + + +
{stats.events}
+

{stats.confirmed} zugesagt

+
+
+ + + Gefahrene Stages + + +
{stats.stages}
+

gewertet

+
+
+ + + Beste Stage-Zeit + + +
+ {stats.best != null ? formatDuration(stats.best) : '—'} +
+
+
+ + + Podestplätze + + +
{stats.podium}
+

Top 3 einer Stage

+
+
+
+ + + + + + Teilnahmen ({rows.length}) + + + + {#if rows.length === 0} +

+ Dieser Fahrer ist bei keinem Event eingetragen. +

+ {:else} + + + + Event + Datum + Nr. + Status + Stages + Gesamtzeit + + + + + {#each rows as row (row.entry.id)} + {@const path = `/dashboard/events/${row.entry.event}/riders/${rider.id}`} + goto(path)}> + + + + {row.event?.name || row.event?.description || 'Event'} + + + + {formatDateRange(row.event?.starts, row.event?.ends) || '—'} + + + {row.entry.number || '—'} + + + + {PARTICIPANT_STATUS_LABEL[row.entry.status] ?? row.entry.status} + + + + + {row.of === 0 ? '—' : `${row.done} von ${row.of} Stages`} + + + {row.done > 0 ? formatDuration(row.total) : '—'} + + + + + + {/each} + + + {/if} +
+
+
+{/if} diff --git a/frontend/src/routes/dashboard/settings/+layout.svelte b/frontend/src/routes/dashboard/settings/+layout.svelte index 8c39ecb..034d2bc 100644 --- a/frontend/src/routes/dashboard/settings/+layout.svelte +++ b/frontend/src/routes/dashboard/settings/+layout.svelte @@ -7,14 +7,23 @@ * keine Einstellung, sondern der Gegenstand der Anwendung. */ 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() - 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/notifications', label: 'Benachrichtigungen', icon: Bell }, { 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 // auf jedem Unterpfad mit aktiv. @@ -25,7 +34,7 @@ } -Einstellungen +Einstellungen · Kurbeler

Einstellungen

diff --git a/frontend/src/routes/dashboard/settings/notifications/+page.svelte b/frontend/src/routes/dashboard/settings/notifications/+page.svelte new file mode 100644 index 0000000..3b7223b --- /dev/null +++ b/frontend/src/routes/dashboard/settings/notifications/+page.svelte @@ -0,0 +1,117 @@ + + +
+ + + + {#if off} + + {:else} + + {/if} + Benachrichtigungen + + + + + +
+ +

+ Es geht eine Mail am Tag 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. +

+
+ + + + {#if prefs.error} +

{prefs.error}

+ {/if} +
+
+ + +
+ {#each NOTIFICATION_GROUPS as group (group.title)} + + + {group.title} + + + {#each group.items as item, i (item.key)} + {#if i > 0} + + {/if} + + {/each} + + + {/each} +
+
diff --git a/frontend/src/routes/dashboard/settings/users/+page.svelte b/frontend/src/routes/dashboard/settings/users/+page.svelte new file mode 100644 index 0000000..12574d8 --- /dev/null +++ b/frontend/src/routes/dashboard/settings/users/+page.svelte @@ -0,0 +1,245 @@ + + +{#if !allowed} + + + +

Nur für Superadmins

+

+ Diese Seite führt alle Konten der App. Die Rolle wird direkt in PocketBase vergeben. +

+
+
+{:else} + + +
+ + + Benutzer + ({records.length}) + + +
+ + +
+
+
+ + + {#if error} +

{error}

+ {/if} + + {#if loading && records.length === 0} +

Wird geladen …

+ {:else if filtered.length === 0} +

+ {records.length === 0 ? 'Keine Konten.' : 'Keine Treffer.'} +

+ {:else} + + + + Name + E-Mail + Teams + Angelegt + Aktion + + + + {#each filtered as u (u.id)} + {@const blocked = blockedReason(u)} + + + + + + {u.name || '— ohne Name —'} + {#if u.superadmin} + + + Superadmin + + {/if} + {#if !u.verified} + unbestätigt + {/if} + + + + + {u.email} + + + + {#if u.teams.length === 0} + in keinem Team + {:else} + {u.teams.join(', ')} + {/if} + + + + {formatDay(u.created) || '—'} + + + + {#if blocked} + + + + + {:else} + + {/if} + + + {/each} + + + {/if} +
+
+{/if} diff --git a/frontend/src/routes/dashboard/team/+page.svelte b/frontend/src/routes/dashboard/team/+page.svelte index f93b643..80da127 100644 --- a/frontend/src/routes/dashboard/team/+page.svelte +++ b/frontend/src/routes/dashboard/team/+page.svelte @@ -20,13 +20,13 @@ * auch die Teamleitung nicht heran. Beim Anlegen setzt riders.createLogin * 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 { app, tooltip } from '$lib/stores/app.svelte' import { getTeamContext } from '$lib/stores/teams.svelte' import { getTeamInviteContext } from '$lib/stores/teamInvites.svelte' import { riderName, getRiderContext } from '$lib/stores/riders.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 { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte' import Avatar from '$lib/components/Avatar.svelte' @@ -42,7 +42,7 @@ import * as Table from '@/components/ui/table' import { 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' import type { RidersResponse, TeamInvitesResponse, TeamsResponse } from '$lib/types' @@ -50,8 +50,6 @@ const invites = getTeamInviteContext() const riders = getRiderContext() const events = getEventContext() - const times = getTimeContext() - const participants = getEventParticipantContext() const trails = getTrailContext() const markers = getTrailMarkerContext() @@ -133,33 +131,36 @@ }) /** - * Zur Fahrerseite geht es über ein Event, denn Zeiten hängen an der Stage und - * der am Event. Bei mehreren Teilnahmen führt der Link zur jüngsten. + * Aus dem Kader geht es aufs Fahrerprofil, nicht in ein Event. + * + * 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) { - const latest = participants.byRider(riderId).at(-1) - return latest ? `/dashboard/events/${latest.event}/riders/${riderId}` : null + return `/dashboard/riders/${riderId}` } - // ---- Person anlegen oder bearbeiten ---------------------------------- + // ---- Fahrer anlegen oder bearbeiten ---------------------------------- // - // Der Dialog bearbeitet die Person, nicht nur den Fahrer: Name und Rolle - // im Team stehen beieinander. Die Rolle lag früher als „Zu Admin" in der + // Der Dialog fasst zusammen, was zu einem Fahrer gehoert: sein Name und + // 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 // Icons fürs Entfernen und Löschen. // // `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 // bearbeitet, sagt deshalb `isEdit`. - let personDialog = $state(false) + let riderDialog = $state(false) let isEdit = $state(false) let editing = $state(null) let editingUser = $state(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 error = $state(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)) /** Die Rolle vergibt allein der Owner, und nie die eigene. */ @@ -169,9 +170,9 @@ isEdit = false editing = null editingUser = null - form = { name: '', withAccount: false, email: '', password: '', admin: false } + form = { name: '', withAccount: false, email: '', admin: false } error = null - personDialog = true + riderDialog = true } function openEdit(row: Row) { @@ -182,27 +183,20 @@ name: row.rider?.name ?? '', withAccount: false, email: '', - password: '', admin: row.role === 'admin', } error = null - personDialog = true + riderDialog = true } - async function savePerson() { + async function saveRider() { if ((editing || !isEdit) && !form.name.trim()) { error = 'Bitte einen Namen angeben.' return } - if (form.withAccount) { - if (!form.email.trim()) { - error = 'Für ein Konto braucht es eine E-Mail-Adresse.' - return - } - if (form.password.length < 8) { - error = 'Das Passwort braucht mindestens 8 Zeichen.' - return - } + if (form.withAccount && !form.email.trim()) { + error = 'Für die Einladung braucht es eine E-Mail-Adresse.' + return } busy = true @@ -221,74 +215,111 @@ } } else { 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) { - await riders.createLogin(rider as RidersResponse, { - email: form.email, - password: form.password, + await invites.send({ + rider: rider.id, + email: form.email.trim(), }) } } - personDialog = false + riderDialog = false } catch (e: any) { - error = e.message ?? 'Die Person konnte nicht gespeichert werden.' + error = e.message ?? 'Der Fahrer konnte nicht gespeichert werden.' } finally { busy = false } } - // ---- Konto zu einem vorhandenen Fahrer ------------------------------- + // ---- Einladung an einen vorhandenen Fahrer --------------------------- // // Nur diese eine Richtung: aus einem Fahrer ohne Konto einen mit Konto // machen. Ein bestehendes Konto an einen Fahrer zu hängen oder die // 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 accountRider = $state(null) - let accountForm = $state({ email: '', password: '' }) + let accountForm = $state({ email: '', days: 14 }) let accountBusy = $state(false) let accountError = $state(null) + let accountSent = $state(null) function openAccount(rider: RidersResponse) { accountRider = rider - accountForm = { email: '', password: '' } + accountForm = { email: '', days: 14 } accountError = null + accountSent = null accountDialog = true } - async function createAccount() { + async function sendInvite() { if (!accountRider) return if (!accountForm.email.trim()) { - accountError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.' - return - } - if (accountForm.password.length < 8) { - accountError = 'Das Passwort braucht mindestens 8 Zeichen.' + accountError = 'Ohne E-Mail-Adresse geht keine Einladung raus.' return } accountBusy = true accountError = null try { - await riders.createLogin(accountRider, { ...accountForm }) - accountDialog = false + await invites.send({ + rider: accountRider.id, + email: accountForm.email.trim(), + days: accountForm.days, + }) + accountSent = accountForm.email.trim() } catch (e: any) { - accountError = e.message ?? 'Das Konto konnte nicht angelegt werden.' + accountError = e.message ?? 'Die Einladung konnte nicht verschickt werden.' } finally { accountBusy = false } } - async function removeFromTeam(userId: string) { - if (!team) return - if (userId === team.owner) return alert('Der Owner kann nicht entfernt werden.') - if (!confirm('Konto aus dem Team entfernen? Der Fahrer bleibt im Kader.')) return + /** + * Zugang entziehen — der einzige Eingriff, den die Teamleitung an einer + * fremden Person hat. + * + * 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 { - await teams.removeMember(team.id, userId) - } catch (e: any) { - alert(e.message ?? 'Das Mitglied konnte nicht entfernt werden.') - } + app.confirm.request({ + title: 'Zugang entziehen?', + text: + `„${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 ------------------------------------------------- @@ -419,7 +450,7 @@ } -Team +Team · Kurbeler
@@ -517,7 +548,7 @@ {#if canManage} {/if}
@@ -530,7 +561,7 @@

Noch niemand im Team

{#if canManage} - Leg die erste Person an oder verschick einen Einladungslink. + Leg den ersten Fahrer an oder verschick einen Einladungslink. {:else} Die Teamleitung legt den Kader an. {/if} @@ -543,10 +574,7 @@ Name - Konto Rolle - Events - Zeiten {#if canManage} Aktionen {/if} @@ -555,7 +583,24 @@ {#each filtered as row (row.rider?.id ?? row.userId)} {@const path = row.rider ? riderPath(row.rider.id) : null} - + goto(path) : undefined} + > + @@ -564,35 +609,22 @@ {:else} {row.name} {/if} + {#if row.userId} + + + + + {/if} {#if !row.rider} kein Fahrer {/if} - - - {#if row.userId} - - - kann sich anmelden - - {:else} - kein Konto - {/if} - - {#if row.role === 'owner'} @@ -611,21 +643,13 @@ {/if} - - {row.rider ? participants.byRider(row.rider.id).length : '—'} - - - - {row.rider ? times.getByRider(row.rider.id).length : '—'} - - {#if canManage} - + e.stopPropagation()}> {#if row.rider && !row.userId} + {#if row.userId && row.role !== 'owner'} {/if} - - {#if row.rider} - - {/if} {/if} @@ -675,15 +698,15 @@

- - + + - {isEdit ? 'Person bearbeiten' : 'Person anlegen'} + {isEdit ? 'Fahrer bearbeiten' : 'Fahrer anlegen'} {isEdit ? '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.'} @@ -725,26 +748,29 @@ {/if} {#if !isEdit} + {#if form.withAccount}
- -
-
- - +

- Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt - sich unter „Einstellungen" ändern. + Der Fahrer bekommt einen Link und setzt sein Passwort selbst. Ohne + Häkchen steht er nur im Kader — einladen lässt er sich jederzeit + später aus seiner Zeile.

{/if} @@ -756,8 +782,8 @@
- - + @@ -769,35 +795,64 @@ - Konto für {accountRider ? riderName(accountRider) : 'Fahrer'} + {accountRider ? riderName(accountRider) : 'Fahrer'} einladen - Mit einem Konto kann sich die Person selbst anmelden. Gib das Passwort - weiter; ändern lässt es sich unter „Einstellungen". + Es geht eine Mail mit einem Link raus. Das Passwort setzt der Fahrer + dabei selbst — du bekommst es nicht zu sehen und musst es nicht + weitergeben. -
-
- - -
-
- - + {#if accountSent} + +
+

+ Einladung an {accountSent} ist unterwegs. +

+

+ Kommt nichts an, lohnt der Blick in den Spam-Ordner. Danach lässt sich + hier eine neue Einladung schicken — die alte verfällt damit. +

- {#if accountError} -

{accountError}

- {/if} -
+ + + + {:else} +
+
+ + +
- - - - +
+ + +

+ Tage. 0 heißt: läuft nicht ab. +

+
+ + {#if accountError} +

{accountError}

+ {/if} +
+ + + + + + {/if} @@ -928,7 +983,7 @@ Logo für „{logoTeam?.name}" Erscheint in der Kopfzeile und blass im Seitenhintergrund. Ohne eigenes - Logo steht dort das Wappen des Stammtischs. + Logo steht dort die Kurbel. diff --git a/frontend/src/routes/dashboard/trails/+page.svelte b/frontend/src/routes/dashboard/trails/+page.svelte index 2e4146b..6d38027 100644 --- a/frontend/src/routes/dashboard/trails/+page.svelte +++ b/frontend/src/routes/dashboard/trails/+page.svelte @@ -121,7 +121,13 @@ {@const freshComments = comments.hasRecent(trail.id)} - + +
{trail.name} (null) let loadError = $state(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(null) + let form = $state({ name: '', email: '', password: '', passwordConfirm: '' }) let busy = $state(false) let error = $state(null) onMount(async () => { 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) { loadError = e.message ?? 'Diese Einladung gibt es nicht mehr.' } finally { @@ -90,7 +108,7 @@ } -Einladung +Einladung · Kurbeler
@@ -123,6 +141,9 @@ {#if auth.user} 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} Leg dir ein Konto an — danach stehst du als Fahrer im Kader. {/if} @@ -163,15 +184,28 @@
+ + {#if recipient?.personal} +

+ An diese Adresse ging die Einladung. +

+ {/if}
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index d1a5702..32af266 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -2,11 +2,11 @@ import { auth } from '$lib/stores/pocketbase.svelte' import { goto } from '$app/navigation' 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 { Label } from '@/components/ui/label' 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 password = $state('') @@ -35,12 +35,12 @@ } -Login +Kurbeler
- + Melde dich an, um fortzufahren diff --git a/frontend/static/kurbeler-icon-192.png b/frontend/static/kurbeler-icon-192.png new file mode 100644 index 0000000..646d84d Binary files /dev/null and b/frontend/static/kurbeler-icon-192.png differ diff --git a/frontend/static/kurbeler-icon-512.png b/frontend/static/kurbeler-icon-512.png new file mode 100644 index 0000000..d994d94 Binary files /dev/null and b/frontend/static/kurbeler-icon-512.png differ diff --git a/frontend/static/kurbeler-icon-maskable.svg b/frontend/static/kurbeler-icon-maskable.svg new file mode 100644 index 0000000..ab224b2 --- /dev/null +++ b/frontend/static/kurbeler-icon-maskable.svg @@ -0,0 +1 @@ + diff --git a/frontend/static/kurbeler-icon.svg b/frontend/static/kurbeler-icon.svg new file mode 100644 index 0000000..a1652fa --- /dev/null +++ b/frontend/static/kurbeler-icon.svg @@ -0,0 +1 @@ + diff --git a/frontend/static/kurbeler-logo-dark.svg b/frontend/static/kurbeler-logo-dark.svg new file mode 100644 index 0000000..f2f4357 --- /dev/null +++ b/frontend/static/kurbeler-logo-dark.svg @@ -0,0 +1 @@ + diff --git a/frontend/static/kurbeler-logo.svg b/frontend/static/kurbeler-logo.svg new file mode 100644 index 0000000..cb217fc --- /dev/null +++ b/frontend/static/kurbeler-logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/static/kurbeler-mark.svg b/frontend/static/kurbeler-mark.svg new file mode 100644 index 0000000..cf6e3b6 --- /dev/null +++ b/frontend/static/kurbeler-mark.svg @@ -0,0 +1 @@ + diff --git a/frontend/static/manifest.webmanifest b/frontend/static/manifest.webmanifest index e52a07f..18e097a 100644 --- a/frontend/static/manifest.webmanifest +++ b/frontend/static/manifest.webmanifest @@ -1,7 +1,7 @@ { - "name": "Stammtisch Hersbruck", - "short_name": "Stammtisch", - "description": "Events, Stages, Zeiten und Trails des Stammtisch Hersbruck", + "name": "Kurbeler", + "short_name": "Kurbeler", + "description": "Events, Stages, Zeiten und Trails", "start_url": "/dashboard", "scope": "/", "display": "standalone", @@ -11,10 +11,28 @@ "lang": "de", "icons": [ { - "src": "/schildl.svg", + "src": "/kurbeler-icon.svg", "sizes": "any", "type": "image/svg+xml", "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" } ] } diff --git a/frontend/static/pedaler.svg b/frontend/static/pedaler.svg deleted file mode 100644 index 8e9c4e4..0000000 --- a/frontend/static/pedaler.svg +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/static/stammtisch-hersbruck.svg b/frontend/static/stammtisch-hersbruck.svg new file mode 100644 index 0000000..0f62193 --- /dev/null +++ b/frontend/static/stammtisch-hersbruck.svg @@ -0,0 +1 @@ + \ No newline at end of file