Compare commits
1 commit
master
...
abgebroche
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
279796111c |
39
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/`
|
||||
|
|
|
|||
31
NAMING.md
|
|
@ -1,31 +0,0 @@
|
|||
Die "Wanderer"-Schiene
|
||||
|
||||
Shredder: Ziemlich actiongeladen, 100% MTB-Jargon und hat genau wie "Wanderer" dieses aktive, ausführende Element.
|
||||
|
||||
Kurbler: Die direktere, etwas rotzigere deutsche Alternative. Hat einen leichten Retro-Charme.
|
||||
|
||||
Dropper: Angelehnt an die Dropper-Post (absenkbare Sattelstütze) oder das "Reindroppen" in den Trail.
|
||||
|
||||
Fokus auf Trail & Community
|
||||
|
||||
TrailTribe: Betont den Vereins- und Gruppenaspekt hervorragend.
|
||||
|
||||
DirtBase: Perfekt für die Traildatenbank – kurz, prägnant und technisch sauber.
|
||||
|
||||
FlowHub: Verbindet das ultimative MTB-Gefühl ("Flow") mit dem modernen Plattform-Gedanken für Eventveranstalter.
|
||||
|
||||
Fokus auf Zeitnahme & Action
|
||||
|
||||
SendSync: "Send it!" gepaart mit der schnellen Datensynchronisation einer Webapp.
|
||||
|
||||
PacePeak: Klingt sportlich, kompetitiv und extrem passend für Rennveranstalter und Bestzeitenjäger.
|
||||
|
||||
TrailTicker: Etwas verspielter, bringt aber das Thema Zeitmessung und Live-Updates sofort auf den Punkt.
|
||||
|
||||
Kurz & Knackig (Startup-Vibe)
|
||||
|
||||
Berm: Englisch für die Anliegerkurve. Kurz, international und unter Mountainbikern ein fester Begriff.
|
||||
|
||||
Roost: Der aufgeworfene Dreck in der Kurve. Hat Energie und ist sehr spezifisch für den Offroad-Sport.
|
||||
|
||||
Stollen: Ein sehr deutsches, geerdetes Wort (Stollenreifen), das MTB-Flair mit technischer Präzision verbindet.
|
||||
19
README.md
|
|
@ -1,7 +1,24 @@
|
|||
# stammtisch-hersbruck.de
|
||||
# stammtisch-hersbruck.de — abgelöst
|
||||
|
||||
> **Dieses Repository wird nicht weiterentwickelt.**
|
||||
>
|
||||
> Die App heißt seit dem 08.09.2026 **Kurbeler** und wird unter
|
||||
> `~/Dokumente/Projekte/kurbeler` fortgeführt. Dort liegt die vollständige
|
||||
> Historie dieses Repos — es ist kein Neuanfang, sondern dieselbe Geschichte
|
||||
> unter neuem Namen.
|
||||
>
|
||||
> Der Stammtisch Hersbruck bekommt **kein** eigenes Deployment mehr, sondern
|
||||
> ist ein Team in Kurbeler. Die PocketBase-Instanz hinter
|
||||
> `api.stammtisch-hersbruck.de` bleibt dieselbe und wird nur umbenannt — es
|
||||
> zieht kein einziger Datensatz um.
|
||||
|
||||
|
||||
Zeitmessung und Verwaltung für Läufe des Stammtisch Hersbruck.
|
||||
|
||||
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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
74
backend/pb_hooks/directory.pb.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Das Benutzerverzeichnis des Superadmins.
|
||||
//
|
||||
// GET /api/directory/users — alle Konten der App, nur Superadmin
|
||||
//
|
||||
// Warum eine eigene Route und keine Collection-Regel? Wegen der E-Mail.
|
||||
// Konten entstehen mit `emailVisibility: false`, PocketBase gibt die Adresse
|
||||
// damit nur ihrem Inhaber heraus — und ein Verzeichnis ohne Adressen wäre
|
||||
// eine Liste von Namen, in der niemand ein Konto wiedererkennt. Das Flag
|
||||
// umzulegen wäre eine Zeile und zugleich ein Loch: `users.listRule` gilt
|
||||
// appweit (`@request.auth.id != ""`), jede angemeldete Person läse danach
|
||||
// jede Adresse, quer über alle Teams.
|
||||
//
|
||||
// Der Hook liest sie stattdessen serverseitig und gibt sie nur dem einen
|
||||
// heraus, der sie sehen darf.
|
||||
//
|
||||
// Jeder Handler holt sich seine Helfer selbst per require(): PocketBase führt
|
||||
// Route-Handler in einer eigenen JS-Laufzeit aus, der Dateikopf hier ist dort
|
||||
// nicht sichtbar.
|
||||
|
||||
// --- Alle Konten der App ----------------------------------------------------
|
||||
//
|
||||
// Das Verzeichnis des Superadmins. Es führt Konten, nicht Fahrer: Ein Fahrer
|
||||
// gehört einem Team und wird dort verwaltet, ein Konto gehört niemandem außer
|
||||
// sich selbst — und genau deshalb kann es auch nur hier fallen.
|
||||
//
|
||||
// `owns` sagt, welche Teams das Konto besitzt. Die Oberfläche braucht das,
|
||||
// bevor sie den Löschknopf anbietet: `teams.owner` ist eine Pflichtrelation
|
||||
// ohne cascadeDelete, ein Löschen liefe sonst in einen Datenbankfehler statt
|
||||
// in einen Satz, den jemand lesen kann.
|
||||
|
||||
routerAdd('GET', '/api/directory/users', (e) => {
|
||||
const lib = require(`${__hooks}/directory_lib.js`)
|
||||
|
||||
const user = e.auth
|
||||
if (!user || !lib.isSuperadmin(e.app, user)) {
|
||||
throw new ForbiddenError('Nur für Superadmins.')
|
||||
}
|
||||
|
||||
const teams = lib.allTeams(e.app)
|
||||
const records = e.app.findAllRecords(lib.USERS)
|
||||
const out = []
|
||||
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const u = records[i]
|
||||
const memberOf = []
|
||||
const owns = []
|
||||
|
||||
for (let t = 0; t < teams.length; t++) {
|
||||
const team = teams[t]
|
||||
if (lib.ownerId(team) === u.id) {
|
||||
owns.push(team.getString('name'))
|
||||
}
|
||||
if (lib.isMember(team, u.id)) {
|
||||
memberOf.push(team.getString('name'))
|
||||
}
|
||||
}
|
||||
|
||||
out.push({
|
||||
id: u.id,
|
||||
name: u.getString('name'),
|
||||
email: u.getString('email'),
|
||||
verified: u.getBool('verified'),
|
||||
superadmin: u.getBool('superadmin'),
|
||||
created: u.getString('created'),
|
||||
avatar: u.getString('avatar'),
|
||||
teams: memberOf,
|
||||
owns: owns,
|
||||
})
|
||||
}
|
||||
|
||||
return e.json(200, { users: out })
|
||||
}, $apis.requireAuth())
|
||||
78
backend/pb_hooks/directory_lib.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Gemeinsamer Code der Verzeichnis-Route.
|
||||
//
|
||||
// Eigene Datei aus demselben Grund wie bei den Einladungen: PocketBase führt
|
||||
// jeden Route-Handler in einer eigenen JS-Laufzeit aus. Was neben routerAdd
|
||||
// steht, sieht der Handler nicht — er holt sich seine Helfer per require().
|
||||
|
||||
const USERS = 'users'
|
||||
const TEAMS = 'teams'
|
||||
|
||||
/**
|
||||
* Die IDs einer Relation als gewöhnliches Array.
|
||||
*
|
||||
* `getStringSlice` und nicht `get`: Es liefert bei Einzel- wie
|
||||
* Mehrfachrelation dieselbe Form und kommt auch mit dem leeren Feld zurecht.
|
||||
* `get` gäbe je nach Feld einen String oder eine Go-Slice zurück — zwei
|
||||
* Formen, die der Aufrufer auseinanderhalten müsste.
|
||||
*/
|
||||
function idsOf(record, field) {
|
||||
const raw = record.getStringSlice(field) || []
|
||||
const out = []
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
if (raw[i]) {
|
||||
out.push(raw[i])
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** Wem gehört das Team? Leerer String, wenn der Owner fehlt. */
|
||||
function ownerId(team) {
|
||||
const ids = idsOf(team, 'owner')
|
||||
return ids.length > 0 ? ids[0] : ''
|
||||
}
|
||||
|
||||
/** Ist die Person Mitglied (oder Owner) dieses Teams? */
|
||||
function isMember(team, userId) {
|
||||
if (ownerId(team) === userId) {
|
||||
return true
|
||||
}
|
||||
|
||||
return idsOf(team, 'users').indexOf(userId) !== -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Superadmin ist eine appweite Rolle auf dem eigenen users-Datensatz, kein
|
||||
* PocketBase-Superuser. Gelesen wird sie frisch aus der Datenbank und nicht
|
||||
* aus dem mitgeschickten Auth-Record: Der Token hält den Stand von der
|
||||
* Anmeldung fest, eine entzogene Rolle wirkte sonst bis zum Ablauf nicht.
|
||||
*/
|
||||
function isSuperadmin(app, user) {
|
||||
if (!user) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return app.findRecordById(USERS, user.id).getBool('superadmin')
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Alle Teams — für die Zuordnung im Superadmin-Verzeichnis. */
|
||||
function allTeams(app) {
|
||||
return app.findAllRecords(TEAMS)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
USERS: USERS,
|
||||
idsOf: idsOf,
|
||||
ownerId: ownerId,
|
||||
isMember: isMember,
|
||||
isSuperadmin: isSuperadmin,
|
||||
allTeams: allTeams,
|
||||
}
|
||||
112
backend/pb_hooks/mail_lib.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Alles, was in dieser Anwendung eine Mail verschickt, geht hier durch.
|
||||
//
|
||||
// Eigene Datei, weil PocketBase jeden Route- und Event-Handler in einer
|
||||
// eigenen JS-Laufzeit ausführt: Was neben routerAdd steht, sieht der Handler
|
||||
// nicht. Er holt sich seine Helfer per require().
|
||||
//
|
||||
// Drei Dinge stehen hier, die sonst in jeder Mail neu erfunden würden:
|
||||
// woher der Name der App kommt, woher die Adresse des Frontends kommt, und
|
||||
// wie aus einer Vorlage samt Daten eine verschickte Mail wird.
|
||||
|
||||
const DEFAULT_APP_NAME = 'Kurbeler'
|
||||
|
||||
/**
|
||||
* Name der Anwendung, wie er in Mails auftaucht.
|
||||
*
|
||||
* Über `APP_NAME` überschreibbar — eine Testinstanz soll in der Betreffzeile
|
||||
* erkennbar sein, ohne dass jemand den Code anfasst.
|
||||
*/
|
||||
function appName() {
|
||||
return $os.getenv('APP_NAME') || DEFAULT_APP_NAME
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse des Frontends, ohne Schrägstrich am Ende.
|
||||
*
|
||||
* Das ist NICHT `settings().meta.appURL`: Die zeigt auf PocketBase selbst,
|
||||
* und ein Einladungslink dorthin führte auf die API statt auf die Seite, die
|
||||
* das Konto anlegt.
|
||||
*
|
||||
* Und es ist ausdrücklich nicht der `Origin`-Header des Aufrufers. Der steht
|
||||
* unter der Kontrolle dessen, der die Anfrage stellt — ein Link in einer
|
||||
* Mail, den ein Fremder auf seinen eigenen Server zeigen lassen kann, ist
|
||||
* eine Phishing-Vorlage mit unserem Absender darauf.
|
||||
*
|
||||
* Fehlt die Variable, gibt es keine Mail. Lieber ein Fehler, den die
|
||||
* Teamleitung sieht, als ein Link ins Leere, den sie nie zu Gesicht bekommt.
|
||||
*/
|
||||
function appURL() {
|
||||
const raw = ($os.getenv('APP_URL') || '').trim()
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return raw.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/** Eine Vorlage aus pb_hooks/views/ mit Daten füllen. */
|
||||
function render(view, data) {
|
||||
return $template.loadFiles(`${__hooks}/views/${view}`).render(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Absender aus den PocketBase-Einstellungen. Dort wird auch SMTP eingerichtet;
|
||||
* beides gehört ins Admin-UI und nicht in den Code.
|
||||
*/
|
||||
function sender(app) {
|
||||
const meta = app.settings().meta
|
||||
|
||||
return {
|
||||
address: meta.senderAddress,
|
||||
name: meta.senderName || appName(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail verschicken. Wirft weiter, wenn der Versand scheitert — ob das den
|
||||
* Aufrufer scheitern lässt, entscheidet er selbst: Eine Einladung ohne Mail
|
||||
* ist wertlos und muss scheitern, eine Benachrichtigung darf im Stillen
|
||||
* verloren gehen, statt das Speichern eines Events zu verhindern.
|
||||
*/
|
||||
function send(app, to, subject, html) {
|
||||
const message = new MailerMessage({
|
||||
from: sender(app),
|
||||
to: [{ address: to.address, name: to.name || '' }],
|
||||
subject: subject,
|
||||
html: html,
|
||||
})
|
||||
|
||||
app.newMailClient().send(message)
|
||||
}
|
||||
|
||||
/** Vorlage rendern und verschicken — der übliche Weg. */
|
||||
function sendView(app, to, subject, view, data) {
|
||||
send(app, to, subject, render(view, data))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Datum, wie es in einer Mail stehen soll: „30.09.2026".
|
||||
*
|
||||
* PocketBase liefert „2026-09-30 10:00:00.000Z"; das direkt hineinzuschreiben
|
||||
* sähe aus wie ein Datenbankfeld und nicht wie ein Satz.
|
||||
*/
|
||||
function formatDay(value) {
|
||||
const day = (value || '').slice(0, 10)
|
||||
if (day.length !== 10) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const parts = day.split('-')
|
||||
return `${parts[2]}.${parts[1]}.${parts[0]}`
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appName: appName,
|
||||
appURL: appURL,
|
||||
render: render,
|
||||
send: send,
|
||||
sendView: sendView,
|
||||
formatDay: formatDay,
|
||||
}
|
||||
326
backend/pb_hooks/notifications.pb.js
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Die Auslöser und der tägliche Versand.
|
||||
//
|
||||
// Alle Auslöser tun dasselbe: Sie legen einen Eintrag in `notifications` und
|
||||
// sind fertig. Keiner verschickt eine Mail. Das ist der Punkt — sonst löste
|
||||
// ein Kommentar, dem drei Antworten folgen, vier Mails aus, und ein Verein,
|
||||
// der sich die nicht leisten kann, schaltet nach drei Tagen alles ab.
|
||||
//
|
||||
// Einmal am Tag nimmt der Cron-Job unten alles Unversendete, macht je Konto
|
||||
// eine Mail daraus und hakt es ab.
|
||||
//
|
||||
// Alle Hooks hängen an `...AfterCreateSuccess` bzw. `...AfterUpdateSuccess`:
|
||||
// Erst wenn der Datensatz wirklich steht, gibt es etwas zu melden. Und keiner
|
||||
// darf werfen — ein Event anzulegen darf nicht daran scheitern, dass ein
|
||||
// Hinweis nicht geschrieben werden konnte. Die Bibliothek fängt deshalb
|
||||
// selbst ab; hier steht zusätzlich um jeden Handler ein try.
|
||||
|
||||
// --- Neues Event im Team ----------------------------------------------------
|
||||
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
try {
|
||||
const lib = require(`${__hooks}/notifications_lib.js`)
|
||||
|
||||
const name = e.record.getString('name') || e.record.getString('description') || 'Event'
|
||||
const starts = (e.record.getString('starts') || '').slice(0, 10)
|
||||
|
||||
lib.queueForTeam(e.app, e.record.getString('team'), {
|
||||
type: 'event_created',
|
||||
actor: e.record.getString('created_by'),
|
||||
title: name,
|
||||
body: starts ? `Termin: ${starts.split('-').reverse().join('.')}` : 'Noch ohne Termin',
|
||||
link: `/dashboard/events/${e.record.id}`,
|
||||
})
|
||||
} catch (err) {
|
||||
console.log('[notifications] event_created:', err)
|
||||
}
|
||||
|
||||
e.next()
|
||||
}, 'events')
|
||||
|
||||
// --- Termin oder Ort eines Events verschoben --------------------------------
|
||||
//
|
||||
// Nur diese beiden Felder. Ein umbenanntes Event oder ein geänderter
|
||||
// Beschreibungstext ist keine Nachricht wert; ein verschobener Termin schon —
|
||||
// danach richtet sich, wer am Samstag wo steht.
|
||||
|
||||
onRecordAfterUpdateSuccess((e) => {
|
||||
try {
|
||||
const lib = require(`${__hooks}/notifications_lib.js`)
|
||||
|
||||
const before = e.record.original()
|
||||
const movedDate = before.getString('starts') !== e.record.getString('starts')
|
||||
const movedPlace = before.getString('location') !== e.record.getString('location')
|
||||
|
||||
if (!movedDate && !movedPlace) {
|
||||
e.next()
|
||||
return
|
||||
}
|
||||
|
||||
const name = e.record.getString('name') || e.record.getString('description') || 'Event'
|
||||
const starts = (e.record.getString('starts') || '').slice(0, 10)
|
||||
const parts = []
|
||||
if (movedDate) {
|
||||
parts.push(starts ? `neuer Termin ${starts.split('-').reverse().join('.')}` : 'Termin entfernt')
|
||||
}
|
||||
if (movedPlace) {
|
||||
parts.push(`Ort: ${e.record.getString('location') || 'offen'}`)
|
||||
}
|
||||
|
||||
lib.queueForTeam(e.app, e.record.getString('team'), {
|
||||
type: 'event_changed',
|
||||
title: name,
|
||||
body: parts.join(', '),
|
||||
link: `/dashboard/events/${e.record.id}`,
|
||||
})
|
||||
} catch (err) {
|
||||
console.log('[notifications] event_changed:', err)
|
||||
}
|
||||
|
||||
e.next()
|
||||
}, 'events')
|
||||
|
||||
// --- Zu einem Event eingetragen ---------------------------------------------
|
||||
//
|
||||
// Das Gegenstück zu den Teammeldungen: Diese geht an genau einen, und zwar
|
||||
// den, um den es geht. Ein Fahrer ohne Konto bekommt nichts — es gibt keine
|
||||
// Adresse.
|
||||
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
try {
|
||||
const lib = require(`${__hooks}/notifications_lib.js`)
|
||||
|
||||
const userId = lib.userOfRider(e.app, e.record.getString('rider'))
|
||||
if (!userId) {
|
||||
e.next()
|
||||
return
|
||||
}
|
||||
|
||||
const event = e.app.findRecordById('events', e.record.getString('event'))
|
||||
const name = event.getString('name') || event.getString('description') || 'Event'
|
||||
const starts = (event.getString('starts') || '').slice(0, 10)
|
||||
|
||||
lib.queueFor(e.app, userId, {
|
||||
type: 'event_participation',
|
||||
actor: e.record.getString('created_by'),
|
||||
team: event.getString('team'),
|
||||
title: `Du stehst bei „${name}" auf der Liste`,
|
||||
body: starts ? `Termin: ${starts.split('-').reverse().join('.')}` : '',
|
||||
link: `/dashboard/events/${event.id}`,
|
||||
})
|
||||
} catch (err) {
|
||||
console.log('[notifications] event_participation:', err)
|
||||
}
|
||||
|
||||
e.next()
|
||||
}, 'event_participants')
|
||||
|
||||
// --- Neuer Kommentar an einem Trail -----------------------------------------
|
||||
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
try {
|
||||
const lib = require(`${__hooks}/notifications_lib.js`)
|
||||
|
||||
const trail = e.app.findRecordById('trails', e.record.getString('trail'))
|
||||
const author = lib.nameOf(e.app, e.record.getString('created_by'))
|
||||
const text = e.record.getString('text') || ''
|
||||
|
||||
lib.queueForTeam(e.app, e.record.getString('team'), {
|
||||
type: 'trail_comment',
|
||||
actor: e.record.getString('created_by'),
|
||||
title: `${author} zu „${trail.getString('name') || 'Trail'}"`,
|
||||
// Gekürzt: Die Zusammenfassung ist eine Übersicht und nicht der
|
||||
// Ort, an dem man Kommentare liest.
|
||||
body: text.length > 140 ? text.slice(0, 140) + ' …' : text,
|
||||
link: `/dashboard/trails/${trail.id}`,
|
||||
})
|
||||
} catch (err) {
|
||||
console.log('[notifications] trail_comment:', err)
|
||||
}
|
||||
|
||||
e.next()
|
||||
}, 'trail_comments')
|
||||
|
||||
// --- Neue Meldung an der Strecke --------------------------------------------
|
||||
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
try {
|
||||
const lib = require(`${__hooks}/notifications_lib.js`)
|
||||
|
||||
// Erledigte Meldungen entstehen nicht als Meldung — sie werden es.
|
||||
if (e.record.getBool('resolved')) {
|
||||
e.next()
|
||||
return
|
||||
}
|
||||
|
||||
const trail = e.app.findRecordById('trails', e.record.getString('trail'))
|
||||
const author = lib.nameOf(e.app, e.record.getString('created_by'))
|
||||
|
||||
lib.queueForTeam(e.app, e.record.getString('team'), {
|
||||
type: 'trail_marker',
|
||||
actor: e.record.getString('created_by'),
|
||||
title: `${e.record.getString('label') || 'Meldung'} an „${trail.getString('name') || 'Trail'}"`,
|
||||
body: `${author}: ${e.record.getString('note') || 'ohne Notiz'}`,
|
||||
link: `/dashboard/trails/${trail.id}`,
|
||||
})
|
||||
} catch (err) {
|
||||
console.log('[notifications] trail_marker:', err)
|
||||
}
|
||||
|
||||
e.next()
|
||||
}, 'trail_markers')
|
||||
|
||||
// --- Trail gesperrt oder wieder frei ----------------------------------------
|
||||
//
|
||||
// Die eine Trail-Meldung, die standardmäßig an ist: Ob eine Strecke gesperrt
|
||||
// ist, entscheidet, ob jemand am Sonntag hinfährt.
|
||||
|
||||
onRecordAfterUpdateSuccess((e) => {
|
||||
try {
|
||||
const lib = require(`${__hooks}/notifications_lib.js`)
|
||||
|
||||
const before = e.record.original()
|
||||
const status = e.record.getString('status')
|
||||
if (before.getString('status') === status) {
|
||||
e.next()
|
||||
return
|
||||
}
|
||||
|
||||
lib.queueForTeam(e.app, e.record.getString('team'), {
|
||||
type: 'trail_status',
|
||||
actor: e.record.getString('status_by'),
|
||||
title: `„${e.record.getString('name') || 'Trail'}" ist jetzt: ${status}`,
|
||||
body: `Geändert von ${lib.nameOf(e.app, e.record.getString('status_by'))}`,
|
||||
link: `/dashboard/trails/${e.record.id}`,
|
||||
})
|
||||
} catch (err) {
|
||||
console.log('[notifications] trail_status:', err)
|
||||
}
|
||||
|
||||
e.next()
|
||||
}, 'trails')
|
||||
|
||||
// --- Neuer Fahrer im Kader --------------------------------------------------
|
||||
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
try {
|
||||
const lib = require(`${__hooks}/notifications_lib.js`)
|
||||
|
||||
lib.queueForTeam(e.app, e.record.getString('team'), {
|
||||
type: 'team_member',
|
||||
title: `${e.record.getString('name') || 'Jemand'} ist neu im Kader`,
|
||||
body: '',
|
||||
link: '/dashboard/team',
|
||||
})
|
||||
} catch (err) {
|
||||
console.log('[notifications] team_member:', err)
|
||||
}
|
||||
|
||||
e.next()
|
||||
}, 'riders')
|
||||
|
||||
// --- Der tägliche Versand ---------------------------------------------------
|
||||
//
|
||||
// Einmal am Morgen: alles Unversendete einsammeln, je Konto eine Mail, fertig.
|
||||
//
|
||||
// Warum 6 Uhr? Weil die Mail vor dem Frühstück da sein soll und nicht
|
||||
// mittendrin. Die Zeitzone ist die des Containers (TZ), nicht die des
|
||||
// Empfängers — für einen Verein aus einer Gegend ist das dasselbe.
|
||||
//
|
||||
// Der Job ist absichtlich stumpf: Er fragt nach allem ohne `sent`, gruppiert
|
||||
// nach Konto und hakt nur ab, was auch wirklich rausging. Scheitert der
|
||||
// Versand an ein Konto, bleiben dessen Einträge stehen und sind morgen wieder
|
||||
// dabei — eine verspätete Zusammenfassung ist besser als eine verlorene.
|
||||
|
||||
cronAdd('notifications_digest', '0 6 * * *', () => {
|
||||
const lib = require(`${__hooks}/notifications_lib.js`)
|
||||
const mail = require(`${__hooks}/mail_lib.js`)
|
||||
|
||||
const base = mail.appURL()
|
||||
if (!base) {
|
||||
console.log('[notifications] APP_URL fehlt — keine Zusammenfassung verschickt.')
|
||||
return
|
||||
}
|
||||
|
||||
let pending
|
||||
try {
|
||||
// `sent = ""` trifft in PocketBase auch NULL — leer und nicht
|
||||
// gesetzt sind im Filter dasselbe.
|
||||
pending = $app.findRecordsByFilter(lib.QUEUE, 'sent = ""', '+created', 2000, 0)
|
||||
} catch (err) {
|
||||
console.log('[notifications] Warteschlange nicht lesbar:', err)
|
||||
return
|
||||
}
|
||||
|
||||
if (!pending || pending.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Nach Konto bündeln. Ein Konto, eine Mail — darum geht die ganze Übung.
|
||||
const byUser = {}
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
const uid = pending[i].getString('user')
|
||||
if (!byUser[uid]) {
|
||||
byUser[uid] = []
|
||||
}
|
||||
byUser[uid].push(pending[i])
|
||||
}
|
||||
|
||||
for (const uid in byUser) {
|
||||
const items = byUser[uid]
|
||||
|
||||
let user
|
||||
try {
|
||||
user = $app.findRecordById('users', uid)
|
||||
} catch (err) {
|
||||
// Konto gelöscht, Einträge noch da. Abhaken, sonst laufen sie
|
||||
// jede Nacht aufs Neue in denselben Fehler.
|
||||
lib.markSent($app, items)
|
||||
continue
|
||||
}
|
||||
|
||||
const address = user.getString('email')
|
||||
if (!address) {
|
||||
lib.markSent($app, items)
|
||||
continue
|
||||
}
|
||||
|
||||
// Nach Art gruppieren, damit die Mail Abschnitte hat und nicht eine
|
||||
// Liste aus zwanzig gleich aussehenden Zeilen.
|
||||
const groups = []
|
||||
const seen = {}
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const type = items[i].getString('type')
|
||||
if (!seen[type]) {
|
||||
seen[type] = { Label: lib.LABELS[type] || type, Items: [] }
|
||||
groups.push(seen[type])
|
||||
}
|
||||
seen[type].Items.push({
|
||||
Title: items[i].getString('title'),
|
||||
Body: items[i].getString('body'),
|
||||
Link: items[i].getString('link') ? base + items[i].getString('link') : '',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
mail.sendView(
|
||||
$app,
|
||||
{ address: address, name: user.getString('name') },
|
||||
`${mail.appName()}: ${items.length} Neuigkeit${items.length === 1 ? '' : 'en'}`,
|
||||
'digest.html',
|
||||
{
|
||||
App: mail.appName(),
|
||||
Name: user.getString('name') || 'Radlfahrer',
|
||||
Count: items.length,
|
||||
Groups: groups,
|
||||
SettingsLink: `${base}/dashboard/settings/notifications`,
|
||||
},
|
||||
)
|
||||
lib.markSent($app, items)
|
||||
} catch (err) {
|
||||
// Stehen lassen. Morgen früh noch einmal.
|
||||
console.log('[notifications] Versand an', address, 'gescheitert:', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
221
backend/pb_hooks/notifications_lib.js
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Wer erfährt wovon — und was davon in die Warteschlange kommt.
|
||||
//
|
||||
// Eigene Datei, weil PocketBase jeden Hook in einer eigenen JS-Laufzeit
|
||||
// ausführt: Was neben onRecordAfterCreateSuccess steht, sieht der nächste
|
||||
// Handler nicht. Er holt sich seine Helfer per require().
|
||||
|
||||
const PREFS = 'notification_prefs'
|
||||
const QUEUE = 'notifications'
|
||||
|
||||
/**
|
||||
* Was gilt, solange niemand etwas eingestellt hat.
|
||||
*
|
||||
* Die Linie ist bewusst gezogen: An heißt „betrifft mich", aus heißt „ist
|
||||
* viel". Wer zu einem Event eingetragen wird, will das wissen — das passiert
|
||||
* ihm. Jeder Kommentar an jedem Trail des Teams passiert dagegen ständig und
|
||||
* meistens anderen; wer das will, schaltet es ein.
|
||||
*
|
||||
* Diese Voreinstellungen gelten auch dann, wenn ein Konto nie in die
|
||||
* Einstellungen geschaut hat: Ein Datensatz in notification_prefs entsteht
|
||||
* erst beim ersten Speichern.
|
||||
*/
|
||||
const DEFAULTS = {
|
||||
enabled: true,
|
||||
|
||||
event_created: true, // neues Event im Team
|
||||
event_participation: true, // du wurdest eingetragen, dein Status änderte sich
|
||||
event_changed: true, // Termin oder Ort eines Events verschoben
|
||||
trail_comment: false, // jeder Kommentar an jedem Trail
|
||||
trail_marker: false, // jede neue Meldung an der Strecke
|
||||
trail_status: true, // ein Trail wurde gesperrt oder wieder frei
|
||||
team_member: false, // jemand ist ins Team gekommen
|
||||
}
|
||||
|
||||
/** Überschriften der Arten, wie sie in der Zusammenfassung stehen. */
|
||||
const LABELS = {
|
||||
event_created: 'Neue Events',
|
||||
event_participation: 'Deine Teilnahmen',
|
||||
event_changed: 'Geänderte Events',
|
||||
trail_comment: 'Kommentare',
|
||||
trail_marker: 'Meldungen an der Strecke',
|
||||
trail_status: 'Trail-Status',
|
||||
team_member: 'Neu im Team',
|
||||
}
|
||||
|
||||
/**
|
||||
* Einstellungen eines Kontos, ergänzt um alles, was nicht drinsteht.
|
||||
*
|
||||
* Ein fehlender Datensatz ist kein Sonderfall, sondern der Normalfall bei
|
||||
* jedem, der die Einstellungen nie geöffnet hat — er bekommt DEFAULTS.
|
||||
*/
|
||||
function prefsOf(app, userId) {
|
||||
let record = null
|
||||
try {
|
||||
record = app.findFirstRecordByFilter(PREFS, 'user = {:user}', { user: userId })
|
||||
} catch (err) {
|
||||
record = null
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return DEFAULTS
|
||||
}
|
||||
|
||||
const out = { enabled: record.getBool('enabled') }
|
||||
for (const key in DEFAULTS) {
|
||||
if (key !== 'enabled') {
|
||||
out[key] = record.getBool(key)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** Will dieses Konto diese Art von Meldung? */
|
||||
function wants(app, userId, type) {
|
||||
const prefs = prefsOf(app, userId)
|
||||
return !!prefs.enabled && !!prefs[type]
|
||||
}
|
||||
|
||||
/**
|
||||
* Die IDs einer Relation als gewöhnliches Array — wie im Verzeichnis-Modul.
|
||||
* `get()` gäbe je nach Feld einen String oder eine Go-Slice zurück.
|
||||
*/
|
||||
function idsOf(record, field) {
|
||||
const raw = record.getStringSlice(field) || []
|
||||
const out = []
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
if (raw[i]) {
|
||||
out.push(raw[i])
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** Alle Konten eines Teams: Mitglieder und der Owner. */
|
||||
function teamMembers(app, teamId) {
|
||||
let team
|
||||
try {
|
||||
team = app.findRecordById('teams', teamId)
|
||||
} catch (err) {
|
||||
return []
|
||||
}
|
||||
|
||||
const ids = idsOf(team, 'users')
|
||||
const owner = idsOf(team, 'owner')[0]
|
||||
if (owner && ids.indexOf(owner) === -1) {
|
||||
ids.push(owner)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Eintrag in die Warteschlange legen — für ein Konto.
|
||||
*
|
||||
* Wirft nie. Ein Auslöser hängt immer an einem Speichervorgang, den jemand
|
||||
* gerade ausgelöst hat: Ein neues Event darf nicht daran scheitern, dass ein
|
||||
* Hinweis nicht geschrieben werden konnte.
|
||||
*/
|
||||
function queueFor(app, userId, entry) {
|
||||
if (!userId || !entry || !entry.type) {
|
||||
return
|
||||
}
|
||||
|
||||
// Der Auslöser selbst bekommt keine Meldung über sein eigenes Tun. Wer
|
||||
// ein Event anlegt, weiß, dass es eins gibt.
|
||||
if (entry.actor && entry.actor === userId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!wants(app, userId, entry.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
const record = new Record(app.findCollectionByNameOrId(QUEUE))
|
||||
record.set('user', userId)
|
||||
record.set('type', entry.type)
|
||||
record.set('title', entry.title || '')
|
||||
record.set('body', entry.body || '')
|
||||
record.set('link', entry.link || '')
|
||||
if (entry.team) {
|
||||
record.set('team', entry.team)
|
||||
}
|
||||
app.save(record)
|
||||
} catch (err) {
|
||||
console.log('[notifications] konnte nicht einreihen:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** Denselben Eintrag für ein ganzes Team einreihen. */
|
||||
function queueForTeam(app, teamId, entry) {
|
||||
const members = teamMembers(app, teamId)
|
||||
const withTeam = Object.assign({}, entry, { team: teamId })
|
||||
|
||||
for (let i = 0; i < members.length; i++) {
|
||||
queueFor(app, members[i], withTeam)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Konto hinter einem Fahrer — oder leer, wenn er keins hat.
|
||||
* Ohne Konto gibt es keine Adresse und damit nichts zu benachrichtigen.
|
||||
*/
|
||||
function userOfRider(app, riderId) {
|
||||
try {
|
||||
return app.findRecordById('riders', riderId).getString('user')
|
||||
} catch (err) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Anzeigename eines Kontos für Sätze wie „X hat kommentiert". */
|
||||
function nameOf(app, userId) {
|
||||
if (!userId) {
|
||||
return 'Jemand'
|
||||
}
|
||||
|
||||
try {
|
||||
const u = app.findRecordById('users', userId)
|
||||
return u.getString('name') || u.getString('email') || 'Jemand'
|
||||
} catch (err) {
|
||||
return 'Jemand'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Einträge als versendet abhaken. Einzeln, damit ein hängender Datensatz
|
||||
* nicht die ganze Gruppe mitreißt und morgen alles doppelt rausgeht.
|
||||
*/
|
||||
function markSent(app, items) {
|
||||
const now = new Date().toISOString().replace('T', ' ')
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
items[i].set('sent', now)
|
||||
app.save(items[i])
|
||||
} catch (err) {
|
||||
console.log('[notifications] konnte nicht abhaken:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PREFS: PREFS,
|
||||
QUEUE: QUEUE,
|
||||
DEFAULTS: DEFAULTS,
|
||||
LABELS: LABELS,
|
||||
prefsOf: prefsOf,
|
||||
wants: wants,
|
||||
idsOf: idsOf,
|
||||
teamMembers: teamMembers,
|
||||
queueFor: queueFor,
|
||||
queueForTeam: queueForTeam,
|
||||
userOfRider: userOfRider,
|
||||
nameOf: nameOf,
|
||||
markSent: markSent,
|
||||
}
|
||||
|
|
@ -38,7 +38,10 @@ routerAdd('GET', '/api/team-invites/{token}', (e) => {
|
|||
throw new NotFoundError('Diese Einladung gibt es nicht mehr.')
|
||||
}
|
||||
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
94
backend/pb_hooks/views/digest.html
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<!doctype html>
|
||||
<!--
|
||||
Die Tageszusammenfassung — eine Mail statt zwanzig.
|
||||
|
||||
Gerendert mit $template.loadFiles(...).render(data), also Go's
|
||||
html/template. {{ }} wird dabei escaped; Kommentartexte und Trailnamen
|
||||
kommen aus der Datenbank und dürfen hier kein Markup einschleusen.
|
||||
|
||||
Tabellen und Inline-Styles aus demselben Grund wie in invite.html:
|
||||
Mailprogramme lesen kein CSS aus dem <head> und Outlook rendert mit Word.
|
||||
|
||||
Erwartete Daten:
|
||||
.App Name der Anwendung
|
||||
.Name Anrede
|
||||
.Count Wie viele Einträge insgesamt
|
||||
.Groups [{ Label, Items: [{ Title, Body, Link }] }]
|
||||
.SettingsLink Direkt zu den Benachrichtigungs-Einstellungen
|
||||
-->
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.App}} — Zusammenfassung</title>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background:#f4f4f5; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
||||
<div style="display:none; max-height:0; overflow:hidden; opacity:0;">
|
||||
{{.Count}} Neuigkeiten aus deinen Teams.
|
||||
</div>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5; padding:32px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
style="max-width:560px; background:#ffffff; border-radius:12px; overflow:hidden;">
|
||||
<tr>
|
||||
<td style="padding:32px 32px 0 32px;">
|
||||
<p style="margin:0 0 4px 0; font-size:13px; letter-spacing:0.08em; text-transform:uppercase; color:#a1a1aa;">
|
||||
{{.App}}
|
||||
</p>
|
||||
<h1 style="margin:0 0 8px 0; font-size:22px; line-height:1.3; color:#18181b;">
|
||||
Was seit gestern passiert ist
|
||||
</h1>
|
||||
<p style="margin:0; font-size:15px; color:#71717a;">
|
||||
Hallo {{.Name}} — {{.Count}} Neuigkeit{{if ne .Count 1}}en{{end}}.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{range .Groups}}
|
||||
<tr>
|
||||
<td style="padding:28px 32px 0 32px;">
|
||||
<p style="margin:0 0 12px 0; font-size:12px; font-weight:700; letter-spacing:0.08em; text-transform:uppercase; color:#a1a1aa;">
|
||||
{{.Label}}
|
||||
</p>
|
||||
|
||||
{{range .Items}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
style="margin:0 0 10px 0; border-left:3px solid #eab308; background:#fafafa; border-radius:0 8px 8px 0;">
|
||||
<tr>
|
||||
<td style="padding:12px 14px;">
|
||||
<p style="margin:0; font-size:15px; font-weight:600; color:#18181b;">
|
||||
{{if .Link}}<a href="{{.Link}}" style="color:#18181b; text-decoration:none;">{{.Title}}</a>{{else}}{{.Title}}{{end}}
|
||||
</p>
|
||||
{{if .Body}}
|
||||
<p style="margin:4px 0 0 0; font-size:14px; line-height:1.5; color:#71717a;">
|
||||
{{.Body}}
|
||||
</p>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
<tr>
|
||||
<td style="padding:28px 32px 32px 32px;">
|
||||
<hr style="border:0; border-top:1px solid #e4e4e7; margin:0 0 16px 0;">
|
||||
<p style="margin:0; font-size:13px; line-height:1.6; color:#a1a1aa;">
|
||||
Zu viel oder zu wenig? Unter
|
||||
<a href="{{.SettingsLink}}" style="color:#71717a;">Einstellungen → Benachrichtigungen</a>
|
||||
stellst du ein, worüber diese Mail berichtet — oder schaltest sie ganz ab.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:16px 0 0 0; font-size:12px; color:#a1a1aa;">{{.App}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
116
backend/pb_hooks/views/invite.html
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<!doctype html>
|
||||
<!--
|
||||
Einladungsmail für ein neues Konto.
|
||||
|
||||
Gerendert wird sie mit $template.loadFiles(...).render(data) — Go's
|
||||
html/template. Die Platzhalter stehen deshalb in {{ }} und werden dabei
|
||||
escaped; {{.Team}} kommt aus einer Datenbank und darf hier kein Markup
|
||||
einschleusen.
|
||||
|
||||
Warum Tabellen und Inline-Styles? Weil Mailprogramme kein CSS aus dem
|
||||
<head> lesen, kein Flexbox können und Outlook bis heute mit Word rendert.
|
||||
Was hier steht, ist die Form, die überall ankommt — nicht die, die man
|
||||
schreiben würde.
|
||||
|
||||
Erwartete Daten:
|
||||
.App Name der Anwendung
|
||||
.Team Team, das einlädt
|
||||
.Name Name des Eingeladenen, wie er im Kader steht
|
||||
.Link Vollständige URL auf /invite/{token}
|
||||
.Expires Klartext wie "30. September 2026" — leer heißt: läuft nicht ab
|
||||
-->
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Einladung zu {{.Team}}</title>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background:#f4f4f5; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
||||
<!-- Vorschautext: steht in der Liste neben dem Betreff und sonst nirgends. -->
|
||||
<div style="display:none; max-height:0; overflow:hidden; opacity:0;">
|
||||
{{.Team}} lädt dich zu {{.App}} ein. Passwort setzt du selbst.
|
||||
</div>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5; padding:32px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
style="max-width:520px; background:#ffffff; border-radius:12px; overflow:hidden;">
|
||||
<tr>
|
||||
<td style="padding:32px 32px 8px 32px;">
|
||||
<p style="margin:0 0 4px 0; font-size:13px; letter-spacing:0.08em; text-transform:uppercase; color:#a1a1aa;">
|
||||
{{.App}}
|
||||
</p>
|
||||
<h1 style="margin:0; font-size:24px; line-height:1.3; color:#18181b;">
|
||||
{{.Team}} lädt dich ein
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:16px 32px 0 32px; font-size:16px; line-height:1.6; color:#3f3f46;">
|
||||
<p style="margin:0 0 16px 0;">Hallo {{.Name}},</p>
|
||||
<p style="margin:0 0 16px 0;">
|
||||
du stehst im Kader von <strong>{{.Team}}</strong>. Mit einem eigenen
|
||||
Konto siehst du Termine, trägst dich zu Events ein und findest deine
|
||||
Zeiten wieder.
|
||||
</p>
|
||||
<p style="margin:0 0 24px 0;">
|
||||
Über den Knopf legst du das Konto selbst an — <strong>dein Passwort
|
||||
wählst du dabei selbst</strong>, niemand sonst kennt es.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:0 32px;">
|
||||
<!-- Der Knopf als Tabelle: Ein <a> mit Padding bricht in Outlook. -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="border-radius:8px; background:#eab308;">
|
||||
<a href="{{.Link}}"
|
||||
style="display:inline-block; padding:14px 28px; font-size:16px; font-weight:600; color:#18181b; text-decoration:none;">
|
||||
Konto anlegen
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:24px 32px 0 32px; font-size:14px; line-height:1.6; color:#71717a;">
|
||||
<p style="margin:0 0 8px 0;">
|
||||
Falls der Knopf nicht geht, diese Adresse in den Browser kopieren:
|
||||
</p>
|
||||
<p style="margin:0; word-break:break-all;">
|
||||
<a href="{{.Link}}" style="color:#3f3f46;">{{.Link}}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{if .Expires}}
|
||||
<tr>
|
||||
<td style="padding:16px 32px 0 32px; font-size:14px; line-height:1.6; color:#71717a;">
|
||||
Die Einladung gilt bis zum {{.Expires}}.
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
<tr>
|
||||
<td style="padding:24px 32px 32px 32px;">
|
||||
<hr style="border:0; border-top:1px solid #e4e4e7; margin:0 0 16px 0;">
|
||||
<p style="margin:0; font-size:13px; line-height:1.6; color:#a1a1aa;">
|
||||
Du erwartest keine Einladung? Dann ignorier diese Mail einfach —
|
||||
ohne den Link entsteht kein Konto.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:16px 0 0 0; font-size:12px; color:#a1a1aa;">{{.App}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -2144,6 +2144,47 @@ migrate((app) => {
|
|||
"system": false,
|
||||
"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
|
||||
|
|
|
|||
38
backend/pb_migrations/1757340000_users_superadmin_delete.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Konten löschen darf der Superadmin.
|
||||
//
|
||||
// Bis hierher galt `users.deleteRule = "id = @request.auth.id"`: Ein Konto
|
||||
// konnte allein sein Inhaber löschen. Das ist die richtige Voreinstellung —
|
||||
// eine Teamleitung hat an fremden Konten nichts verloren, sie verwaltet
|
||||
// Fahrer und Mitgliedschaften, nicht Menschen. Genau dabei bleibt es; im
|
||||
// Kader gibt es seitdem nur noch „Zugang zum Team entziehen".
|
||||
//
|
||||
// Was fehlte, war die Stelle darüber: Ein Konto, das niemand mehr braucht und
|
||||
// dessen Inhaber sich nicht selbst darum kümmert, blieb für immer stehen. Das
|
||||
// erledigt jetzt der Superadmin im Benutzer-Verzeichnis unter Einstellungen.
|
||||
//
|
||||
// Der Vorspann `@request.auth.id != ""` ist Pflicht (siehe
|
||||
// 1754501700_rules_require_login.js): Ohne ihn wäre `@request.auth.superadmin`
|
||||
// einer anonymen Anfrage ein leerer Wert — und leer gegen leer verglichen ist
|
||||
// in PocketBase wahr.
|
||||
//
|
||||
// Gelöscht wird nur das Konto. Fahrer, Teilnahmen und Zeiten hängen am Team
|
||||
// und bleiben stehen; sie sind dessen Geschichte und nicht die des Logins.
|
||||
// Ein Team, dessen Owner das Konto ist, blockiert die Löschung — `teams.owner`
|
||||
// ist eine Pflichtrelation ohne cascadeDelete. Die Oberfläche prüft das vorher
|
||||
// und sagt, welches Team im Weg steht.
|
||||
|
||||
const RULE = 'id = @request.auth.id || (@request.auth.id != "" && @request.auth.superadmin = true)'
|
||||
|
||||
migrate((app) => {
|
||||
const users = app.findCollectionByNameOrId('users')
|
||||
|
||||
users.deleteRule = RULE
|
||||
app.save(users)
|
||||
}, (app) => {
|
||||
const users = app.findCollectionByNameOrId('users')
|
||||
|
||||
users.deleteRule = 'id = @request.auth.id'
|
||||
app.save(users)
|
||||
})
|
||||
83
backend/pb_migrations/1757600000_invites_personal.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Persönliche Einladungen — der zweite Weg zu einem Konto.
|
||||
//
|
||||
// Bis hierher gab es zwei Wege ins Team, und einer davon war schief:
|
||||
//
|
||||
// 1. Der Einladungslink. Er gilt für jeden, der ihn hat; wer ihn einlöst,
|
||||
// legt sich selbst ein Konto an und vergibt sein eigenes Passwort.
|
||||
// Dieser Weg bleibt unverändert.
|
||||
//
|
||||
// 2. „Konto anlegen" im Kader. Dort tippte die Teamleitung eine Adresse
|
||||
// und ein Passwort ein — und musste dieses Passwort anschließend
|
||||
// irgendwie zum Fahrer bringen. Über einen Kanal, den niemand geplant
|
||||
// hat, im Zweifel per WhatsApp. Sie kannte es außerdem, und der Fahrer
|
||||
// hatte ein Passwort, das er sich nicht ausgesucht hat.
|
||||
//
|
||||
// Der zweite Weg wird zur Einladungsmail: Die Teamleitung gibt nur noch die
|
||||
// Adresse an, PocketBase verschickt einen Link, und der Fahrer setzt sein
|
||||
// Passwort selbst. Niemand außer ihm kennt es je.
|
||||
//
|
||||
// Dafür bekommt `team_invites` drei Felder. Eine persönliche Einladung ist
|
||||
// derselbe Datensatz wie ein Link, nur mit Adressat:
|
||||
//
|
||||
// email — an wen sie ging. Gesetzt heißt: Diese Einladung gilt nur für
|
||||
// diese Adresse, die Anmeldeseite lässt keine andere zu.
|
||||
// rider — welcher Fahrer sein Konto bekommt. Ohne dieses Feld legte der
|
||||
// Beitritt einen zweiten Fahrer an und dieselbe Person stünde
|
||||
// doppelt im Kader. cascadeDelete: Verschwindet der Fahrer,
|
||||
// verfällt seine Einladung mit ihm.
|
||||
// sent — wann die Mail rausging. Nur zur Anzeige („vor 3 Tagen
|
||||
// eingeladen"), damit niemand raten muss, ob schon eine unterwegs
|
||||
// ist.
|
||||
//
|
||||
// Alle drei sind optional. Ein Einladungslink lässt sie leer und verhält
|
||||
// sich wie zuvor — die Felder erweitern den Datensatz, sie verändern ihn
|
||||
// nicht.
|
||||
|
||||
migrate((app) => {
|
||||
const invites = app.findCollectionByNameOrId('team_invites')
|
||||
const riders = app.findCollectionByNameOrId('riders')
|
||||
|
||||
if (!invites.fields.getByName('email')) {
|
||||
invites.fields.add(new Field({
|
||||
id: 'email_invite_email',
|
||||
name: 'email',
|
||||
type: 'text',
|
||||
}))
|
||||
}
|
||||
|
||||
if (!invites.fields.getByName('rider')) {
|
||||
invites.fields.add(new Field({
|
||||
id: 'relation_invite_rider',
|
||||
name: 'rider',
|
||||
type: 'relation',
|
||||
required: false,
|
||||
maxSelect: 1,
|
||||
collectionId: riders.id,
|
||||
cascadeDelete: true,
|
||||
}))
|
||||
}
|
||||
|
||||
if (!invites.fields.getByName('sent')) {
|
||||
invites.fields.add(new Field({
|
||||
id: 'date_invite_sent',
|
||||
name: 'sent',
|
||||
type: 'date',
|
||||
}))
|
||||
}
|
||||
|
||||
// Die createRule bleibt, wie sie ist: Einladungen legt die Teamleitung
|
||||
// an. Persönliche entstehen ohnehin nicht über die Collection, sondern
|
||||
// über POST /api/team-invites/send — der Hook prüft dieselbe Bedingung
|
||||
// und verschickt in einem Zug die Mail.
|
||||
app.save(invites)
|
||||
}, (app) => {
|
||||
const invites = app.findCollectionByNameOrId('team_invites')
|
||||
|
||||
invites.fields.removeByName('email')
|
||||
invites.fields.removeByName('rider')
|
||||
invites.fields.removeByName('sent')
|
||||
|
||||
app.save(invites)
|
||||
})
|
||||
158
backend/pb_migrations/1757700000_notifications.js
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Benachrichtigungen — eine Mail am Tag, und jeder bestimmt selbst, worüber.
|
||||
//
|
||||
// Zwei Collections, weil zwei verschiedene Dinge gebraucht werden:
|
||||
//
|
||||
// notification_prefs — was will jemand wissen? Ein Datensatz je Konto.
|
||||
// notifications — was ist passiert? Die Warteschlange bis zum Versand.
|
||||
//
|
||||
// Warum eine Warteschlange und nicht sofort eine Mail? Weil sonst ein
|
||||
// Trailkommentar, dem drei Antworten folgen, vier Mails auslöst — und ein
|
||||
// Verein, der sich die nicht leisten kann, das Ganze am dritten Tag
|
||||
// abschaltet. Der Cron-Job in pb_hooks/notifications.pb.js nimmt einmal
|
||||
// täglich alles Unversendete, macht eine Mail daraus und hakt es ab.
|
||||
//
|
||||
// Die Warteschlange ist deshalb kein Zwischenspeicher, sondern der eigentliche
|
||||
// Mechanismus: Der Auslöser schreibt hinein und ist fertig. Ob und wann etwas
|
||||
// verschickt wird, entscheidet allein der Job. Ein Fehler beim Mailversand
|
||||
// bringt damit auch nie das Speichern eines Events durcheinander.
|
||||
//
|
||||
// Wer nichts einstellt, hat trotzdem Voreinstellungen: `notification_prefs`
|
||||
// entsteht erst beim ersten Speichern. Was ohne Datensatz gilt, steht als
|
||||
// DEFAULTS in pb_hooks/notifications_lib.js — an einer Stelle, gültig für
|
||||
// Auslöser und Oberfläche.
|
||||
|
||||
const OWN_RECORD = '@request.auth.id != "" && user = @request.auth.id'
|
||||
|
||||
migrate((app) => {
|
||||
const users = app.findCollectionByNameOrId('users')
|
||||
const teams = app.findCollectionByNameOrId('teams')
|
||||
|
||||
// --- Einstellungen ----------------------------------------------------
|
||||
//
|
||||
// Alles, was jemand über sich selbst festlegt. Die Regeln lassen nur den
|
||||
// eigenen Datensatz zu — auch beim Anlegen, sonst schriebe jemand
|
||||
// Einstellungen für ein fremdes Konto und bestimmte, was ein anderer
|
||||
// erfährt.
|
||||
try {
|
||||
app.findCollectionByNameOrId('notification_prefs')
|
||||
} catch {
|
||||
const prefs = new Collection({
|
||||
id: 'pbc_notification_prefs',
|
||||
type: 'base',
|
||||
name: 'notification_prefs',
|
||||
listRule: OWN_RECORD,
|
||||
viewRule: OWN_RECORD,
|
||||
createRule: '@request.auth.id != "" && @request.body.user = @request.auth.id',
|
||||
updateRule: OWN_RECORD,
|
||||
deleteRule: OWN_RECORD,
|
||||
fields: [
|
||||
{
|
||||
id: 'relation_prefs_user',
|
||||
name: 'user',
|
||||
type: 'relation',
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
// Hauptschalter. Aus heißt: gar keine Mails, egal was die
|
||||
// einzelnen Schalter sagen. Er steht vor allem für den Fall,
|
||||
// dass jemand für zwei Wochen Ruhe haben will, ohne sich
|
||||
// danach alles neu zusammenzuklicken.
|
||||
{ id: 'bool_prefs_enabled', name: 'enabled', type: 'bool' },
|
||||
|
||||
{ id: 'bool_prefs_event_created', name: 'event_created', type: 'bool' },
|
||||
{ id: 'bool_prefs_event_participation', name: 'event_participation', type: 'bool' },
|
||||
{ id: 'bool_prefs_event_changed', name: 'event_changed', type: 'bool' },
|
||||
{ id: 'bool_prefs_trail_comment', name: 'trail_comment', type: 'bool' },
|
||||
{ id: 'bool_prefs_trail_status', name: 'trail_status', type: 'bool' },
|
||||
{ id: 'bool_prefs_trail_marker', name: 'trail_marker', type: 'bool' },
|
||||
{ id: 'bool_prefs_team_member', name: 'team_member', type: 'bool' },
|
||||
|
||||
{ id: 'autodate_created', name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
|
||||
{ id: 'autodate_updated', name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: [
|
||||
// Ein Datensatz je Konto. Zwei wären zwei Wahrheiten, und
|
||||
// welche gilt, entschiede die Sortierung.
|
||||
'CREATE UNIQUE INDEX `idx_notification_prefs_user` ON `notification_prefs` (`user`)',
|
||||
],
|
||||
})
|
||||
|
||||
app.save(prefs)
|
||||
}
|
||||
|
||||
// --- Warteschlange ----------------------------------------------------
|
||||
//
|
||||
// Lesen darf jeder seine eigenen Einträge — daraus wird später einmal
|
||||
// eine Glocke in der Oberfläche. Schreiben darf über die API niemand:
|
||||
// Alle vier Regeln stehen auf null, was in PocketBase „nur Superuser"
|
||||
// heißt. Gefüllt wird ausschließlich aus den Hooks, und die laufen an den
|
||||
// Regeln vorbei. Sonst schriebe sich jeder selbst Meldungen — oder,
|
||||
// schlimmer, anderen.
|
||||
try {
|
||||
app.findCollectionByNameOrId('notifications')
|
||||
} catch {
|
||||
const queue = new Collection({
|
||||
id: 'pbc_notifications',
|
||||
type: 'base',
|
||||
name: 'notifications',
|
||||
listRule: OWN_RECORD,
|
||||
viewRule: OWN_RECORD,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
id: 'relation_notification_user',
|
||||
name: 'user',
|
||||
type: 'relation',
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{
|
||||
id: 'relation_notification_team',
|
||||
name: 'team',
|
||||
type: 'relation',
|
||||
required: false,
|
||||
maxSelect: 1,
|
||||
collectionId: teams.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
// Welcher Schalter diesen Eintrag erlaubt hat. Steht mit
|
||||
// drin, damit die Zusammenfassung nach Art gruppieren kann.
|
||||
{ id: 'text_notification_type', name: 'type', type: 'text', required: true },
|
||||
{ id: 'text_notification_title', name: 'title', type: 'text', required: true },
|
||||
{ id: 'text_notification_body', name: 'body', type: 'text' },
|
||||
// Relativer Pfad, kein vollständiger Link: Die Adresse der
|
||||
// App steht in APP_URL und gehört nicht in jeden Datensatz.
|
||||
{ id: 'text_notification_link', name: 'link', type: 'text' },
|
||||
// Leer heißt: wartet auf die nächste Zusammenfassung.
|
||||
{ id: 'date_notification_sent', name: 'sent', type: 'date' },
|
||||
{ id: 'autodate_created', name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
|
||||
{ id: 'autodate_updated', name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: [
|
||||
// Der Job fragt genau danach: alles Unversendete, nach Konto.
|
||||
'CREATE INDEX `idx_notifications_pending` ON `notifications` (`user`, `sent`)',
|
||||
],
|
||||
})
|
||||
|
||||
app.save(queue)
|
||||
}
|
||||
}, (app) => {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId('notifications'))
|
||||
} catch {
|
||||
// Schon fort.
|
||||
}
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId('notification_prefs'))
|
||||
} catch {
|
||||
// Schon fort.
|
||||
}
|
||||
})
|
||||
49
design/logo/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Kurbeler — Logo
|
||||
|
||||
Der Name kommt vom Bauteil, das Zeichen auch: eine **Tretkurbel**. Kegeliger
|
||||
Arm, dick am Tretlager, dünn am Pedal, an beiden Enden eine Bohrung. Aus dieser
|
||||
einen Geometrie sind alle drei Varianten gebaut.
|
||||
|
||||
| Ordner | Variante | |
|
||||
|---|---|---|
|
||||
| `kurbel-k/` | **Kurbel-K** | Das K *ist* die Kurbel: zwei Arme aus dem Tretlager. Buchstabe und Bauteil in einer Form. **Im Einsatz.** |
|
||||
| `kurbelarm/` | Kurbelarm | Ein einzelner Arm. Die ruhigste Lösung, braucht den Schriftzug daneben. |
|
||||
| `garnitur/` | Kurbelgarnitur | Beide Arme in einer Linie, 180°, wie sie sich am Rad gegenüberstehen. |
|
||||
|
||||
## Dateien je Variante
|
||||
|
||||
| Datei | Wofür |
|
||||
|---|---|
|
||||
| `mark.svg` | Bildmarke allein, in `currentColor` |
|
||||
| `icon.svg` | App-Symbol: gelbe Kachel, Marke in Tinte |
|
||||
| `icon-dark.svg` | dasselbe umgekehrt, für dunkle Flächen |
|
||||
| `icon-maskable.svg` | randlos für Android, Marke im sicheren Bereich (62 %) |
|
||||
| `logo.svg` / `logo-dark.svg` | Marke und Schriftzug nebeneinander |
|
||||
| `logo-mono.svg` | dasselbe einfarbig, alles `currentColor` |
|
||||
| `icon-192.png`, `icon-512.png` | Raster für Betriebssysteme ohne SVG |
|
||||
|
||||
## Regeln
|
||||
|
||||
- **Zwei Farben, nie drei.** Kurbelgelb `#eab308` und Tinte `#18181b`, in der
|
||||
einen oder der anderen Richtung. Kein Verlauf, kein Schatten.
|
||||
- **Die Bohrungen sind Löcher.** Sie liegen als gegenläufig gewickelte Kreise
|
||||
im selben Pfad, durch sie scheint der Untergrund. Nichts dahinterlegen, was
|
||||
nicht durchscheinen soll.
|
||||
- **Abstand zum Schriftzug**: ein Viertel der Markenhöhe. Darunter fällt die
|
||||
Kombination auseinander.
|
||||
- **Schriftzug**: Inter ExtraBold, Laufweite −0,023 em, in Kurven gewandelt
|
||||
(`word.d`). Als Pfad, damit er ohne Schriftdatei überall gleich aussieht.
|
||||
|
||||
## Neu bauen
|
||||
|
||||
```bash
|
||||
python3 design/logo/build.py
|
||||
```
|
||||
|
||||
Braucht Inkscape (verschmilzt die Teilformen zu einem Umriss) und
|
||||
`rsvg-convert` (PNG). Das Skript legt alle Varianten neu an und kopiert die
|
||||
gesetzte — Kurbel-K — unter ihren Markennamen nach `frontend/static/`.
|
||||
|
||||
Die Pfaddaten für die App stehen zusätzlich in
|
||||
`frontend/src/lib/brand.ts`; ändert sich die Form, muss die Datei mit den
|
||||
Werten aus `geometry.json` nachgezogen werden.
|
||||
186
design/logo/build.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Erzeugt saemtliche Logodateien aus einer einzigen Geometrie.
|
||||
|
||||
Die Form entsteht nicht von Hand, sondern aus Punkten und Radien: ein
|
||||
Kurbelarm ist die Huellkurve zweier Kreise (dick am Tretlager, duenn am
|
||||
Pedal), die Bohrungen sind gegenlaeufig gewickelte Kreise im selben Pfad.
|
||||
Wer die Marke aendert, aendert die Zahlen hier oben und laesst das Skript
|
||||
laufen — dann stimmen App-Symbol, Schriftzug und PNG wieder zueinander.
|
||||
|
||||
python3 design/logo/build.py
|
||||
|
||||
Braucht Inkscape (Vereinigung der Teilformen) und rsvg-convert (PNG).
|
||||
Der Schriftzug liegt als fertiger Pfad in `word.d`; er stammt aus Inter
|
||||
ExtraBold, in Kurven gewandelt, und wird hier nur noch skaliert.
|
||||
"""
|
||||
import json, math, os, re, subprocess
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(os.path.dirname(HERE))
|
||||
STATIC = os.path.join(ROOT, "frontend", "static")
|
||||
|
||||
YELLOW = "#eab308" # yellow-500, die Themenfarbe der App
|
||||
INK = "#18181b" # zinc-900
|
||||
PAPER = "#fafaf9"
|
||||
|
||||
# Schriftzug: Inter ExtraBold, 128 px, Laufweite -3, in Pfade gewandelt.
|
||||
WORD = open(os.path.join(HERE, "word.d")).read().strip()
|
||||
WORD_BOX = dict(x=17.0455, y=46.9091, w=517.182, h=94.4091)
|
||||
|
||||
|
||||
def hull(p1, r1, p2, r2):
|
||||
"""Aussenkontur zweier Kreise — ein kegeliger Kurbelarm."""
|
||||
(x1, y1), (x2, y2) = p1, p2
|
||||
dx, dy = x2 - x1, y2 - y1
|
||||
th = math.atan2(dy, dx)
|
||||
be = math.acos(max(-1, min(1, (r1 - r2) / math.hypot(dx, dy))))
|
||||
at = lambda c, r, a: (c[0] + r * math.cos(a), c[1] + r * math.sin(a))
|
||||
A, B = at(p1, r1, th + be), at(p2, r2, th + be)
|
||||
C, D = at(p2, r2, th - be), at(p1, r1, th - be)
|
||||
return (f'M{A[0]:.3f} {A[1]:.3f}L{B[0]:.3f} {B[1]:.3f}'
|
||||
f'A{r2} {r2} 0 0 0 {C[0]:.3f} {C[1]:.3f}'
|
||||
f'L{D[0]:.3f} {D[1]:.3f}A{r1} {r1} 0 0 0 {A[0]:.3f} {A[1]:.3f}Z')
|
||||
|
||||
|
||||
def bore(c, r):
|
||||
"""Bohrung: gegenlaeufig gewickelt, damit sie im selben Pfad ein Loch ist."""
|
||||
x, y = c
|
||||
return (f'M{x - r:.3f} {y:.3f}A{r} {r} 0 1 1 {x + r:.3f} {y:.3f}'
|
||||
f'A{r} {r} 0 1 1 {x - r:.3f} {y:.3f}Z')
|
||||
|
||||
|
||||
def rrect(x, y, w, h, r):
|
||||
return (f'M{x + r} {y}H{x + w - r}A{r} {r} 0 0 1 {x + w} {y + r}V{y + h - r}'
|
||||
f'A{r} {r} 0 0 1 {x + w - r} {y + h}H{x + r}A{r} {r} 0 0 1 {x} {y + h - r}'
|
||||
f'V{y + r}A{r} {r} 0 0 1 {x + r} {y}Z')
|
||||
|
||||
|
||||
def union(name, parts):
|
||||
"""Teilformen zu einem Umriss verschmelzen — sonst zeigen sich an den
|
||||
Ueberlappungen Nahtkanten, und die Bohrungen wuerden dort zufaellig
|
||||
wieder zuwachsen."""
|
||||
body = "".join(f'<path id="p{i}" d="{d}" fill="#000"/>' for i, d in enumerate(parts))
|
||||
src = os.path.join(HERE, f".{name}-parts.svg")
|
||||
dst = os.path.join(HERE, f".{name}-union.svg")
|
||||
open(src, "w").write(
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" '
|
||||
f'width="64" height="64">{body}</svg>')
|
||||
subprocess.run(["inkscape", src, "--actions",
|
||||
f"select-all;path-union;export-plain-svg;"
|
||||
f"export-filename:{dst};export-do"], capture_output=True)
|
||||
ds = re.findall(r'\sd="([^"]+)"', open(dst).read(), re.S)
|
||||
assert len(ds) == 1, f"{name}: {len(ds)} Pfade statt einem"
|
||||
os.remove(src); os.remove(dst)
|
||||
return " ".join(ds[0].split())
|
||||
|
||||
|
||||
# --- Die drei Varianten -----------------------------------------------------
|
||||
# Tretlager J, Pedalaugen UP/LO. Die Arme sind am Lager dick (8.5) und am
|
||||
# Pedal duenn (5.5) — so herum ist eine Kurbel gebaut.
|
||||
J, UP, LO = (18.5, 32), (45, 13), (45, 51)
|
||||
|
||||
VARIANTS = {
|
||||
"kurbel-k": dict(
|
||||
name="Kurbel-K",
|
||||
parts=[rrect(10, 10.5, 9.5, 43, 4.75), hull(J, 8.5, UP, 5.5), hull(J, 8.5, LO, 5.5)],
|
||||
bores=[(J, 4.4), (UP, 2.7), (LO, 2.7)],
|
||||
),
|
||||
"kurbelarm": dict(
|
||||
name="Kurbelarm",
|
||||
parts=[hull((23, 14), 9.5, (41, 50), 6.2)],
|
||||
bores=[((23, 14), 4.5), ((41, 50), 3.0)],
|
||||
),
|
||||
"garnitur": dict(
|
||||
name="Kurbelgarnitur",
|
||||
parts=[hull((32, 32), 10, (16.5, 47.5), 6.2), hull((32, 32), 10, (47.5, 16.5), 6.2)],
|
||||
bores=[((32, 32), 4.8), ((16.5, 47.5), 3.0), ((47.5, 16.5), 3.0)],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def ink_box(path):
|
||||
"""Tintenrahmen der fertigen Form — fuers optische Zentrieren."""
|
||||
tmp = os.path.join(HERE, ".measure.svg")
|
||||
open(tmp, "w").write(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" '
|
||||
f'width="64" height="64"><path d="{path}" fill="#000"/></svg>')
|
||||
out = subprocess.run(["inkscape", tmp, "--query-all"], capture_output=True, text=True).stdout
|
||||
os.remove(tmp)
|
||||
x, y, w, h = (float(v) for v in out.splitlines()[0].split(",")[1:5])
|
||||
return x, y, x + w, y + h
|
||||
|
||||
|
||||
def svg(w, h, body, view=None):
|
||||
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{view or f"0 0 {w} {h}"}" '
|
||||
f'width="{w}" height="{h}">{body}</svg>\n')
|
||||
|
||||
|
||||
def build(key, spec, outdir):
|
||||
path = union(key, spec["parts"]) + "".join(bore(c, r) for c, r in spec["bores"])
|
||||
x0, y0, x1, y1 = ink_box(path)
|
||||
mid = lambda s: (32 - (x0 + x1) / 2 * s, 32 - (y0 + y1) / 2 * s)
|
||||
|
||||
def centred(scale=1.0):
|
||||
dx, dy = mid(scale)
|
||||
return f'translate({dx:.4f} {dy:.4f}) scale({scale})'
|
||||
|
||||
def word_at(colour, cap, x):
|
||||
s = cap / WORD_BOX["h"]
|
||||
return (f'<g transform="translate({x - WORD_BOX["x"] * s:.3f} '
|
||||
f'{32 - cap / 2 - WORD_BOX["y"] * s:.3f}) scale({s:.5f})">'
|
||||
f'<path fill="{colour}" d="{WORD}"/></g>'), WORD_BOX["w"] * s
|
||||
|
||||
files = {}
|
||||
files["mark.svg"] = svg(64, 64,
|
||||
f'<g transform="{centred()}"><path fill="currentColor" d="{path}"/></g>')
|
||||
# Die Kachel: Marke auf 84 %, damit sie nicht an die Rundung stoesst.
|
||||
files["icon.svg"] = svg(64, 64,
|
||||
f'<rect width="64" height="64" rx="14" fill="{YELLOW}"/>'
|
||||
f'<g transform="{centred(0.84)}"><path fill="{INK}" d="{path}"/></g>')
|
||||
files["icon-dark.svg"] = svg(64, 64,
|
||||
f'<rect width="64" height="64" rx="14" fill="{INK}"/>'
|
||||
f'<g transform="{centred(0.84)}"><path fill="{YELLOW}" d="{path}"/></g>')
|
||||
# Randlos fuer Android: 62 %, alles ausserhalb kann beschnitten werden.
|
||||
files["icon-maskable.svg"] = svg(64, 64,
|
||||
f'<rect width="64" height="64" fill="{YELLOW}"/>'
|
||||
f'<g transform="{centred(0.62)}"><path fill="{INK}" d="{path}"/></g>')
|
||||
|
||||
for fn, mc, tc in (("logo.svg", YELLOW, INK),
|
||||
("logo-dark.svg", YELLOW, PAPER),
|
||||
("logo-mono.svg", "currentColor", "currentColor")):
|
||||
g, ww = word_at(tc, 38, x1 + 15)
|
||||
files[fn] = svg(round(x1 + 15 + ww - x0, 2), 64,
|
||||
f'<path fill="{mc}" d="{path}"/>{g}',
|
||||
view=f'{x0:g} 0 {x1 + 15 + ww - x0:.2f} 64')
|
||||
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
for fn, content in files.items():
|
||||
open(os.path.join(outdir, fn), "w").write(content)
|
||||
for px in (192, 512):
|
||||
subprocess.run(["rsvg-convert", "-w", str(px), "-h", str(px),
|
||||
os.path.join(outdir, "icon.svg"),
|
||||
"-o", os.path.join(outdir, f"icon-{px}.png")], check=True)
|
||||
return path, (x0, y0, x1, y1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
geometry = {}
|
||||
for key, spec in VARIANTS.items():
|
||||
path, box = build(key, spec, os.path.join(HERE, key))
|
||||
geometry[key] = dict(path=path, box=box)
|
||||
print(f"{key:12} {spec['name']}")
|
||||
|
||||
# Variante 01 ist die gesetzte: sie wandert unter ihrem Markennamen nach
|
||||
# static/ und wird von Manifest, Favicon und den Komponenten benutzt.
|
||||
live = "kurbel-k"
|
||||
for src, dst in (("mark.svg", "kurbeler-mark.svg"),
|
||||
("icon.svg", "kurbeler-icon.svg"),
|
||||
("icon-maskable.svg", "kurbeler-icon-maskable.svg"),
|
||||
("logo.svg", "kurbeler-logo.svg"),
|
||||
("logo-dark.svg", "kurbeler-logo-dark.svg"),
|
||||
("icon-192.png", "kurbeler-icon-192.png"),
|
||||
("icon-512.png", "kurbeler-icon-512.png")):
|
||||
s = os.path.join(HERE, live, src)
|
||||
open(os.path.join(STATIC, dst), "wb").write(open(s, "rb").read())
|
||||
print(f"\nnach frontend/static/ kopiert: {live}")
|
||||
json.dump(geometry, open(os.path.join(HERE, "geometry.json"), "w"), indent=1)
|
||||
BIN
design/logo/garnitur/icon-192.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
design/logo/garnitur/icon-512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
1
design/logo/garnitur/icon-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#18181b"/><g transform="translate(5.1205 5.1194) scale(0.84)"><path fill="#eab308" d="M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 794 B |
1
design/logo/garnitur/icon-maskable.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" fill="#eab308"/><g transform="translate(12.1603 12.1595) scale(0.62)"><path fill="#18181b" d="M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 788 B |
1
design/logo/garnitur/icon.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#eab308"/><g transform="translate(5.1205 5.1194) scale(0.84)"><path fill="#18181b" d="M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 794 B |
1
design/logo/garnitur/logo-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10.2964 0 266.57 64" width="266.57" height="64"><path fill="#eab308" d="M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z"/><g transform="translate(61.842 -5.881) scale(0.40250)"><path fill="#fafaf9" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
1
design/logo/garnitur/logo-mono.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10.2964 0 266.57 64" width="266.57" height="64"><path fill="currentColor" d="M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z"/><g transform="translate(61.842 -5.881) scale(0.40250)"><path fill="currentColor" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
1
design/logo/garnitur/logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10.2964 0 266.57 64" width="266.57" height="64"><path fill="#eab308" d="M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z"/><g transform="translate(61.842 -5.881) scale(0.40250)"><path fill="#18181b" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
1
design/logo/garnitur/mark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><g transform="translate(0.0006 -0.0008) scale(1.0)"><path fill="currentColor" d="M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 746 B |
29
design/logo/geometry.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"kurbel-k": {
|
||||
"path": "M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z",
|
||||
"box": [
|
||||
10.0,
|
||||
7.5008,
|
||||
50.4985,
|
||||
56.4989
|
||||
]
|
||||
},
|
||||
"kurbelarm": {
|
||||
"path": "M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z",
|
||||
"box": [
|
||||
14.1962,
|
||||
5.89055,
|
||||
47.198899999999995,
|
||||
56.197849999999995
|
||||
]
|
||||
},
|
||||
"garnitur": {
|
||||
"path": "M 47.173828 10.306641 A 6.2 6.2 0 0 0 43.941406 11.421875 L 26.261719 23.810547 A 10 10 0 0 0 25.382812 25.382812 A 10 10 0 0 0 23.810547 26.261719 L 11.421875 43.941406 A 6.2 6.2 0 0 0 20.058594 52.578125 L 37.738281 40.189453 A 10 10 0 0 0 38.617188 38.617188 A 10 10 0 0 0 40.189453 37.738281 L 52.578125 20.058594 A 6.2 6.2 0 0 0 47.173828 10.306641 zM27.200 32.000A4.8 4.8 0 1 1 36.800 32.000A4.8 4.8 0 1 1 27.200 32.000ZM13.500 47.500A3.0 3.0 0 1 1 19.500 47.500A3.0 3.0 0 1 1 13.500 47.500ZM44.500 16.500A3.0 3.0 0 1 1 50.500 16.500A3.0 3.0 0 1 1 44.500 16.500Z",
|
||||
"box": [
|
||||
10.2964,
|
||||
10.2979,
|
||||
53.7025,
|
||||
53.7036
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
design/logo/kurbel-k/icon-192.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
design/logo/kurbel-k/icon-512.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
1
design/logo/kurbel-k/icon-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#18181b"/><g transform="translate(6.5906 5.1201) scale(0.84)"><path fill="#eab308" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 795 B |
1
design/logo/kurbel-k/icon-maskable.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" fill="#eab308"/><g transform="translate(13.2455 12.1601) scale(0.62)"><path fill="#18181b" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 789 B |
1
design/logo/kurbel-k/icon.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#eab308"/><g transform="translate(6.5906 5.1201) scale(0.84)"><path fill="#18181b" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 795 B |
1
design/logo/kurbel-k/logo-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 263.67 64" width="263.67" height="64"><path fill="#eab308" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/><g transform="translate(58.638 -5.881) scale(0.40250)"><path fill="#fafaf9" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
1
design/logo/kurbel-k/logo-mono.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 263.67 64" width="263.67" height="64"><path fill="currentColor" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/><g transform="translate(58.638 -5.881) scale(0.40250)"><path fill="currentColor" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
1
design/logo/kurbel-k/logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 263.67 64" width="263.67" height="64"><path fill="#eab308" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/><g transform="translate(58.638 -5.881) scale(0.40250)"><path fill="#18181b" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
1
design/logo/kurbel-k/mark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><g transform="translate(1.7508 0.0002) scale(1.0)"><path fill="currentColor" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 746 B |
BIN
design/logo/kurbelarm/icon-192.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
design/logo/kurbelarm/icon-512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
1
design/logo/kurbelarm/icon-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#18181b"/><g transform="translate(6.2141 5.9229) scale(0.84)"><path fill="#eab308" d="M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 538 B |
1
design/logo/kurbelarm/icon-maskable.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" fill="#eab308"/><g transform="translate(12.9675 12.7526) scale(0.62)"><path fill="#18181b" d="M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 532 B |
1
design/logo/kurbelarm/icon.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#eab308"/><g transform="translate(6.2141 5.9229) scale(0.84)"><path fill="#18181b" d="M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 538 B |
1
design/logo/kurbelarm/logo-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="14.1962 0 256.17 64" width="256.17" height="64"><path fill="#eab308" d="M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z"/><g transform="translate(55.338 -5.881) scale(0.40250)"><path fill="#fafaf9" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
1
design/logo/kurbelarm/logo-mono.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="14.1962 0 256.17 64" width="256.17" height="64"><path fill="currentColor" d="M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z"/><g transform="translate(55.338 -5.881) scale(0.40250)"><path fill="currentColor" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
1
design/logo/kurbelarm/logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="14.1962 0 256.17 64" width="256.17" height="64"><path fill="#eab308" d="M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z"/><g transform="translate(55.338 -5.881) scale(0.40250)"><path fill="#18181b" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
1
design/logo/kurbelarm/mark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><g transform="translate(1.3025 0.9558) scale(1.0)"><path fill="currentColor" d="M 23.148438 5.90625 A 9.5 9.5 0 0 0 14.880859 18.931641 L 35.701172 53.21875 A 6.2 6.2 0 0 0 46.753906 47.691406 L 31.816406 10.462891 A 9.5 9.5 0 0 0 23.148438 5.90625 zM18.500 14.000A4.5 4.5 0 1 1 27.500 14.000A4.5 4.5 0 1 1 18.500 14.000ZM38.000 50.000A3.0 3.0 0 1 1 44.000 50.000A3.0 3.0 0 1 1 38.000 50.000Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 489 B |
1
design/logo/word.d
Normal file
|
|
@ -0,0 +1 @@
|
|||
M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# stammtisch-hersbruck.de — Frontend
|
||||
# Kurbeler — Frontend
|
||||
|
||||
SvelteKit-5-Anwendung (Svelte 5 Runes, Tailwind 4) für Zeitnahme und Verwaltung.
|
||||
|
||||
|
|
|
|||
28
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
frontend/src/lib/brand.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Die Marke als Geometrie, an einer Stelle.
|
||||
*
|
||||
* Beide Pfade sind fertige Umrisse — der Schriftzug ist in Kurven gewandelte
|
||||
* Inter ExtraBold, keine Textzeile. Er sieht damit ueberall gleich aus, auch
|
||||
* wo Inter nicht installiert ist, und braucht keine Schriftdatei.
|
||||
*
|
||||
* Die Bildmarke ist eine Tretkurbel in Form eines K: zwei kegelige Arme aus
|
||||
* dem Tretlager, die Loecher sitzen an Lager und Pedalgewinde. Es ist *ein*
|
||||
* Pfad mit gegenlaeufig gewickelten Kreisen — die Bohrungen sind echte
|
||||
* Loecher, durch die der Untergrund scheint, keine aufgesetzten Punkte.
|
||||
* Deshalb darf nichts dahinterliegen, was nicht durchscheinen soll.
|
||||
*
|
||||
* Erzeugt aus `frontend/static/kurbeler-*.svg`; wer die Form aendert, aendert
|
||||
* beides.
|
||||
*/
|
||||
|
||||
/** Bildmarke, gezeichnet in einer 64x64-Flaeche. */
|
||||
export const MARK = {
|
||||
path: 'M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z',
|
||||
/** Ruecken die Marke optisch in die Mitte der Flaeche. */
|
||||
dx: 1.7508,
|
||||
dy: 0.0002,
|
||||
}
|
||||
|
||||
/** Marke und Schriftzug nebeneinander, Hoehe 64. */
|
||||
export const LOCKUP = {
|
||||
viewBox: '10 0 263.67 64',
|
||||
word: 'M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z',
|
||||
/** Schriftzug: verschieben, dann verkleinern. */
|
||||
x: 58.638,
|
||||
y: -5.881,
|
||||
scale: 0.40250,
|
||||
}
|
||||
|
|
@ -15,7 +15,20 @@
|
|||
size = 32,
|
||||
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()
|
||||
|
|
|
|||
31
frontend/src/lib/components/Icon.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Das App-Symbol: die Kurbel auf gelber Kachel — dasselbe Bild, das auf
|
||||
* dem Startbildschirm und im Browser-Tab steht.
|
||||
*
|
||||
* Anders als <Mark> faerbt es sich nicht mit: Ein App-Symbol ist ein
|
||||
* Gegenstand, kein Text. Es sieht in hell und dunkel gleich aus, sonst
|
||||
* erkennt es niemand wieder.
|
||||
*/
|
||||
import { MARK } from '$lib/brand'
|
||||
|
||||
let { class: className = 'size-16' }: { class?: string } = $props()
|
||||
|
||||
/**
|
||||
* Die Marke steht auf 84 %, damit sie nicht an die Rundung stoesst.
|
||||
* Verkleinert wird um den Mittelpunkt der Flaeche, nicht um deren Ecke:
|
||||
* erst das Zentrum halten (32 − 32·s), dann die Marke wie gehabt
|
||||
* einruecken (dx·s). Ohne den ersten Summanden rutscht sie nach oben
|
||||
* links aus der Kachel.
|
||||
*/
|
||||
const s = 0.84
|
||||
const dx = $derived(32 - 32 * s + MARK.dx * s)
|
||||
const dy = $derived(32 - 32 * s + MARK.dy * s)
|
||||
</script>
|
||||
|
||||
<svg class={className} viewBox="0 0 64 64" aria-hidden="true">
|
||||
<rect width="64" height="64" rx="14" fill="#eab308" />
|
||||
<g transform="translate({dx} {dy}) scale({s})">
|
||||
<path fill="#18181b" d={MARK.path} />
|
||||
</g>
|
||||
</svg>
|
||||
|
|
@ -1,28 +1,36 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Logo eines Teams. Ist eins hinterlegt, steht es allein; sonst bleibt das
|
||||
* Wappen des Stammtischs als Rückfalloption — zwei übereinanderliegende
|
||||
* SVGs, weil das Radl über dem Schildl leicht übersteht.
|
||||
* Logo eines Teams. Ist eins hinterlegt, steht es allein; sonst faellt es
|
||||
* auf das Zeichen der App zurueck.
|
||||
*
|
||||
* `icon` bestimmt, worauf: In der Kopfleiste steht das App-Symbol, die
|
||||
* gelbe Kachel — dort ist das Logo ein anklickbarer Gegenstand neben
|
||||
* anderen. Als Wasserzeichen und auf den Karten steht die blosse Marke,
|
||||
* die sich mit der Textfarbe faerbt; eine Kachel waere dort ein grosses
|
||||
* gelbes Viereck.
|
||||
*
|
||||
* Groesse ueber eine Klasse steuerbar, damit dasselbe Logo im Header
|
||||
* (klein) und auf der Login-Karte (gross) verwendet werden kann.
|
||||
* (klein), auf der Login-Karte (gross) und als Wasserzeichen (riesig)
|
||||
* verwendet werden kann.
|
||||
*/
|
||||
import { getFileURL } from '$lib/stores/pocketbase.svelte'
|
||||
import type { TeamsResponse } from '$lib/types'
|
||||
import Mark from './Mark.svelte'
|
||||
import Icon from './Icon.svelte'
|
||||
|
||||
let {
|
||||
class: className = 'w-24 h-24',
|
||||
team = null,
|
||||
}: { class?: string; team?: TeamsResponse | null } = $props()
|
||||
icon = false,
|
||||
}: { class?: string; team?: TeamsResponse | null; icon?: boolean } = $props()
|
||||
|
||||
const url = $derived(team?.logo ? getFileURL(team, team.logo) : null)
|
||||
</script>
|
||||
|
||||
<div class="relative {className}" id="logo">
|
||||
{#if url}
|
||||
<img alt="" class="absolute h-full w-full object-contain" src={url} />
|
||||
{:else}
|
||||
<img alt="" class="absolute h-full w-full object-contain" src="/schildl.svg">
|
||||
<img alt="" class="absolute h-full w-full scale-110 object-contain" src="/radl.svg">
|
||||
{/if}
|
||||
</div>
|
||||
{#if url}
|
||||
<img alt="" class="{className} object-contain" src={url} />
|
||||
{:else if icon}
|
||||
<Icon class={className} />
|
||||
{:else}
|
||||
<Mark class="{className} text-primary" />
|
||||
{/if}
|
||||
|
|
|
|||
21
frontend/src/lib/components/Mark.svelte
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Die Bildmarke allein: die Kurbel, in `currentColor`. Sie faerbt sich
|
||||
* also ueber die Textfarbe — `class="text-primary"` macht sie gelb,
|
||||
* im Wasserzeichen erbt sie einfach, was da ist.
|
||||
*/
|
||||
import { MARK } from '$lib/brand'
|
||||
|
||||
let { class: className = 'size-16' }: { class?: string } = $props()
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={className}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g transform="translate({MARK.dx} {MARK.dy})">
|
||||
<path fill="currentColor" d={MARK.path} />
|
||||
</g>
|
||||
</svg>
|
||||
31
frontend/src/lib/components/Wordmark.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Marke und Schriftzug nebeneinander — das grosse Logo fuer Login,
|
||||
* Einladung und alles, wo der Name mitkommen soll.
|
||||
*
|
||||
* Die Hoehe steuert eine Klasse (`h-10`), die Breite ergibt sich aus der
|
||||
* viewBox. Die Kurbel steht in der Akzentfarbe, der Schriftzug in der
|
||||
* Textfarbe — beides Tokens, damit hell und dunkel von selbst stimmen.
|
||||
*/
|
||||
import { LOCKUP, MARK } from '$lib/brand'
|
||||
|
||||
let {
|
||||
class: className = 'h-10',
|
||||
title = 'Kurbeler',
|
||||
}: { class?: string; title?: string } = $props()
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="w-auto {className}"
|
||||
viewBox={LOCKUP.viewBox}
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label={title}
|
||||
>
|
||||
<path class="fill-primary" d={MARK.path} />
|
||||
<g
|
||||
transform="translate({LOCKUP.x} {LOCKUP.y}) scale({LOCKUP.scale})"
|
||||
>
|
||||
<path fill="currentColor" d={LOCKUP.word} />
|
||||
</g>
|
||||
</svg>
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@
|
|||
}: WithElementRef<HTMLThAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<th bind:this={ref} data-slot="table-head" class={cn("text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
|
||||
<!--
|
||||
Spaltenköpfe tragen die Kleinschrift des Hauses (`.label-mono`): schmal,
|
||||
gesperrt, in Versalien. Ein Spaltenkopf benennt ein Feld, er füllt es
|
||||
nicht — vorher stand er in derselben Schrift und Größe wie die Werte
|
||||
darunter und stritt mit ihnen um die Aufmerksamkeit.
|
||||
-->
|
||||
<th bind:this={ref} data-slot="table-head" class={cn("label-mono h-9 px-2 text-left align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</th>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
<thead
|
||||
bind:this={ref}
|
||||
data-slot="table-header"
|
||||
class={cn("[&_tr]:border-b [&_tr]:hover:bg-transparent", className)}
|
||||
class={cn("[&_tr]:border-b-2 [&_tr]:border-border [&_tr]:hover:bg-transparent", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
|
|
|||
182
frontend/src/lib/stores/notificationPrefs.svelte.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { api, auth } from './pocketbase.svelte'
|
||||
import type { NotificationPrefsResponse } from '$lib/types'
|
||||
|
||||
/**
|
||||
* Was jemand per Mail erfahren will.
|
||||
*
|
||||
* Kein Context-Store wie die Collection-Stores: Diese Einstellungen gehören
|
||||
* dem Konto und nicht dem aktiven Team, es gibt sie genau einmal je
|
||||
* Anmeldung. Ein Modul-Singleton ist dafür die ehrlichere Form.
|
||||
*
|
||||
* Ein Datensatz entsteht erst beim ersten Speichern. Bis dahin gilt
|
||||
* `DEFAULTS` — dieselbe Linie wie serverseitig in
|
||||
* backend/pb_hooks/notifications_lib.js: An heißt „betrifft mich", aus heißt
|
||||
* „ist viel". Beide Listen müssen zusammenpassen; stünde hier etwas anderes,
|
||||
* zeigte die Oberfläche einen Schalter an, der nichts mit dem zu tun hat, was
|
||||
* tatsächlich verschickt wird.
|
||||
*/
|
||||
|
||||
export type NotificationType =
|
||||
| 'event_created'
|
||||
| 'event_participation'
|
||||
| 'event_changed'
|
||||
| 'trail_comment'
|
||||
| 'trail_marker'
|
||||
| 'trail_status'
|
||||
| 'team_member'
|
||||
|
||||
export type NotificationPrefs = Record<NotificationType, boolean> & { enabled: boolean }
|
||||
|
||||
export const DEFAULTS: NotificationPrefs = {
|
||||
enabled: true,
|
||||
|
||||
event_created: true,
|
||||
event_participation: true,
|
||||
event_changed: true,
|
||||
trail_comment: false,
|
||||
trail_marker: false,
|
||||
trail_status: true,
|
||||
team_member: false,
|
||||
}
|
||||
|
||||
/** Reihenfolge, Beschriftung und Begründung — was die Oberfläche anzeigt. */
|
||||
export const NOTIFICATION_GROUPS: {
|
||||
title: string
|
||||
items: { key: NotificationType; label: string; hint: string }[]
|
||||
}[] = [
|
||||
{
|
||||
title: 'Events',
|
||||
items: [
|
||||
{
|
||||
key: 'event_created',
|
||||
label: 'Neues Event im Team',
|
||||
hint: 'Sobald jemand einen Termin anlegt.',
|
||||
},
|
||||
{
|
||||
key: 'event_participation',
|
||||
label: 'Du stehst auf der Teilnehmerliste',
|
||||
hint: 'Wenn dich jemand zu einem Event einträgt.',
|
||||
},
|
||||
{
|
||||
key: 'event_changed',
|
||||
label: 'Termin oder Ort verschoben',
|
||||
hint: 'Nur bei Datum und Ort — nicht bei jeder Textänderung.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Trails',
|
||||
items: [
|
||||
{
|
||||
key: 'trail_status',
|
||||
label: 'Ein Trail wird gesperrt oder wieder frei',
|
||||
hint: 'Danach richtet sich, ob die Ausfahrt am Sonntag stattfindet.',
|
||||
},
|
||||
{
|
||||
key: 'trail_marker',
|
||||
label: 'Neue Meldung an der Strecke',
|
||||
hint: 'Umgestürzter Baum, kaputte Anlieger, jede neue Markierung.',
|
||||
},
|
||||
{
|
||||
key: 'trail_comment',
|
||||
label: 'Neuer Kommentar',
|
||||
hint: 'Jeder Kommentar an jedem Trail des Teams. Kann viel werden.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Team',
|
||||
items: [
|
||||
{
|
||||
key: 'team_member',
|
||||
label: 'Jemand ist neu im Kader',
|
||||
hint: 'Bei jedem neu angelegten Fahrer.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const COLLECTION = 'notification_prefs'
|
||||
|
||||
export class NotificationPrefsStore {
|
||||
/** Der Datensatz, sofern es schon einen gibt. */
|
||||
record = $state<NotificationPrefsResponse | null>(null)
|
||||
values = $state<NotificationPrefs>({ ...DEFAULTS })
|
||||
loading = $state(false)
|
||||
saving = $state(false)
|
||||
error = $state<string | null>(null)
|
||||
|
||||
async load() {
|
||||
const uid = auth.user?.id
|
||||
if (!uid) return
|
||||
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const list = await api.collection(COLLECTION).getFullList({
|
||||
filter: `user = "${uid}"`,
|
||||
requestKey: null,
|
||||
})
|
||||
|
||||
const found = (list[0] as NotificationPrefsResponse) ?? null
|
||||
this.record = found
|
||||
|
||||
// Ohne Datensatz bleiben die Voreinstellungen stehen. Sie sind
|
||||
// keine Behelfsanzeige, sondern genau das, was der Server tut.
|
||||
if (found) {
|
||||
const next = { ...DEFAULTS }
|
||||
for (const key of Object.keys(DEFAULTS) as (keyof NotificationPrefs)[]) {
|
||||
next[key] = !!found[key as keyof NotificationPrefsResponse]
|
||||
}
|
||||
this.values = next
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.error = e.message ?? 'Die Einstellungen konnten nicht geladen werden.'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Schalter umlegen und sofort speichern.
|
||||
*
|
||||
* Kein Speichern-Knopf: Es sind Schalter, sie gehören nur dem Kontoinhaber
|
||||
* und jeder für sich ergibt sofort Sinn — genau wie Name und Bild im
|
||||
* Profil. Ein Knopf, der fast immer nichts zu tun hat, ist eine Frage an
|
||||
* jemanden, der längst geantwortet hat.
|
||||
*/
|
||||
async set(key: keyof NotificationPrefs, value: boolean) {
|
||||
const uid = auth.user?.id
|
||||
if (!uid || this.saving) return
|
||||
|
||||
const previous = this.values[key]
|
||||
this.values = { ...this.values, [key]: value }
|
||||
|
||||
this.saving = true
|
||||
this.error = null
|
||||
try {
|
||||
if (this.record) {
|
||||
this.record = (await api
|
||||
.collection(COLLECTION)
|
||||
.update(this.record.id, { [key]: value })) as NotificationPrefsResponse
|
||||
} else {
|
||||
// Der erste Griff an die Schalter legt den Datensatz an — mit
|
||||
// allen Werten, nicht nur dem geänderten. Sonst stünden die
|
||||
// übrigen auf `false` statt auf ihrer Voreinstellung, und
|
||||
// wer einen Schalter ausmacht, hätte nebenbei alle anderen
|
||||
// mit ausgemacht.
|
||||
this.record = (await api.collection(COLLECTION).create({
|
||||
...this.values,
|
||||
user: uid,
|
||||
})) as NotificationPrefsResponse
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.values = { ...this.values, [key]: previous }
|
||||
this.error = e.message ?? 'Das konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationPrefs = new NotificationPrefsStore()
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { getContext, setContext } from 'svelte'
|
||||
import { 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<void>((resolve, reject) => {
|
||||
app.confirm.request({
|
||||
title: 'Fahrer löschen?',
|
||||
text,
|
||||
yes: async () => {
|
||||
try {
|
||||
// Erst die Mitgliedschaft, dann der Fahrer. Andersherum
|
||||
// stünde bei einem Fehler im zweiten Schritt genau das
|
||||
// verwaiste Konto da, das hier verhindert werden soll.
|
||||
if (dropsMembership) {
|
||||
await this.teams.removeMember(team!.id, userId!)
|
||||
}
|
||||
|
||||
await api.collection('riders').delete(record.id)
|
||||
app.confirm.close()
|
||||
resolve()
|
||||
} catch (e) {
|
||||
app.confirm.close()
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
no: () => {
|
||||
app.confirm.close()
|
||||
resolve()
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
// 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<RidersResponse>)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<UsersResponse> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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<InviteTeam> {
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
41
frontend/src/lib/types.d.ts
vendored
|
|
@ -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<Texpand = unknown> = Required<EventParticipantsRecord> & BaseSystemFields<Texpand>
|
||||
export type EventSeriesResponse<Texpand = unknown> = Required<EventSeriesRecord> & BaseSystemFields<Texpand>
|
||||
export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand>
|
||||
export type NotificationPrefsResponse<Texpand = unknown> = Required<NotificationPrefsRecord> & BaseSystemFields<Texpand>
|
||||
export type NotificationsResponse<Texpand = unknown> = Required<NotificationsRecord> & BaseSystemFields<Texpand>
|
||||
export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & BaseSystemFields<Texpand>
|
||||
export type StagesResponse<Texpand = unknown> = Required<StagesRecord> & BaseSystemFields<Texpand>
|
||||
export type TeamInvitesResponse<Texpand = unknown> = Required<TeamInvitesRecord> & BaseSystemFields<Texpand>
|
||||
|
|
@ -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<EventParticipantsResponse>
|
||||
collection(idOrName: 'event_series'): RecordService<EventSeriesResponse>
|
||||
collection(idOrName: 'events'): RecordService<EventsResponse>
|
||||
collection(idOrName: 'notification_prefs'): RecordService<NotificationPrefsResponse>
|
||||
collection(idOrName: 'notifications'): RecordService<NotificationsResponse>
|
||||
collection(idOrName: 'riders'): RecordService<RidersResponse>
|
||||
collection(idOrName: 'stages'): RecordService<StagesResponse>
|
||||
collection(idOrName: 'team_invites'): RecordService<TeamInvitesResponse>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import '../app.css'
|
||||
import favicon from '$lib/assets/favicon.svg'
|
||||
import {ModeWatcher} from "mode-watcher"
|
||||
import {api, auth} from '$lib/stores/pocketbase.svelte'
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
|
||||
|
|
@ -24,7 +23,8 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link href={favicon} rel="icon"/>
|
||||
<link href="/kurbeler-icon.svg" rel="icon" type="image/svg+xml"/>
|
||||
<link href="/kurbeler-icon-192.png" rel="apple-touch-icon"/>
|
||||
</svelte:head>
|
||||
|
||||
<ModeWatcher/>
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@
|
|||
onmouseenter={() => (dashHover = true)}
|
||||
onmouseleave={() => (dashHover = false)}
|
||||
>
|
||||
<Logo team={teams.active} class="size-[4.5rem] -my-3" />
|
||||
<Logo team={teams.active} icon class="size-[4.5rem] -my-3" />
|
||||
</a>
|
||||
|
||||
<!--
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard – Zeitnahme</title>
|
||||
<title>Dashboard · Kurbeler</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-8">
|
||||
|
|
@ -46,39 +46,33 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Drei Kennzahlen, ein Bauplan. Vorher stand jede Kachel dreimal
|
||||
ausgeschrieben da; wer eine anfasste, musste an die anderen beiden
|
||||
denken. Als Snippet gibt es nur noch eine Stelle, an der sich Abstand,
|
||||
Schriftgrad und Reihenfolge entscheiden.
|
||||
|
||||
Die Beschriftung steht in der Kleinschrift des Hauses, der Wert in
|
||||
Mono mit tabellarischen Ziffern — eine 111 ist dadurch genau so breit
|
||||
wie eine 999 und die Kacheln zucken nicht, wenn eine Zeit dazukommt.
|
||||
-->
|
||||
{#snippet stat(label: string, value: number, Icon: typeof Calendar, hint: string)}
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="label-mono">{label}</CardTitle>
|
||||
<Icon class="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="numeric text-3xl font-semibold">{value}</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{hint}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/snippet}
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">Events</CardTitle>
|
||||
<Calendar class="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold">{stats.totalEvents}</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">{stats.activeEvents} heute</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">Fahrer</CardTitle>
|
||||
<Users class="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold">{stats.totalRiders}</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">registriert</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">Laufende Zeiten</CardTitle>
|
||||
<Clock class="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold">{stats.running}</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">{stats.finished} abgeschlossen</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{@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`)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@
|
|||
])
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Events – Zeitnahme</title></svelte:head>
|
||||
<svelte:head><title>Events · Kurbeler</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{event?.name ?? 'Event'} – Zeitnahme</title></svelte:head>
|
||||
<svelte:head><title>{event?.name ?? 'Event'} · Kurbeler</title></svelte:head>
|
||||
|
||||
{#if !event}
|
||||
<Card>
|
||||
|
|
@ -376,7 +393,6 @@
|
|||
<Table.Body>
|
||||
{#each eventParticipants as p (p.id)}
|
||||
{@const rider = riders.getById(p.rider)}
|
||||
{@const done = times.getByRider(p.rider).filter((t) => t.status === 'finished').length}
|
||||
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(p.rider))}>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{p.number || '—'}</Badge>
|
||||
|
|
@ -404,7 +420,11 @@
|
|||
</Table.Cell>
|
||||
{#if event.timing}
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{done} von {eventStages.length} Stages
|
||||
{#if eventStages.length === 0}
|
||||
—
|
||||
{:else}
|
||||
{stagesDone(p.rider)} von {eventStages.length} Stages
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@
|
|||
})
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{rider ? riderName(rider) : 'Fahrer'} – {event?.name ?? 'Event'}</title></svelte:head>
|
||||
<svelte:head><title>{rider ? riderName(rider) : 'Fahrer'} – {event?.name ?? 'Event'} · Kurbeler</title></svelte:head>
|
||||
|
||||
{#if !valid}
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{stage?.description ?? 'Stage'} – {event?.name ?? 'Event'}</title></svelte:head>
|
||||
<svelte:head><title>{stage?.description ?? 'Stage'} – {event?.name ?? 'Event'} · Kurbeler</title></svelte:head>
|
||||
|
||||
{#if !valid}
|
||||
<Card>
|
||||
|
|
|
|||
280
frontend/src/routes/dashboard/riders/[riderId]/+page.svelte
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Das Profil eines Fahrers — über alle Events hinweg.
|
||||
*
|
||||
* Die Fahrerseite unter `/dashboard/events/[id]/riders/[riderId]` zeigt
|
||||
* einen Fahrer *in einem Rennen*. Wer im Kader auf einen Namen klickt,
|
||||
* meint aber die Person und nicht einen ihrer Termine: Hier stehen die
|
||||
* Kennzahlen des Fahrers und darunter jede Teilnahme, von der aus es in
|
||||
* das jeweilige Event weitergeht.
|
||||
*/
|
||||
import { page } from '$app/state'
|
||||
import { goto } from '$app/navigation'
|
||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import {
|
||||
getEventParticipantContext,
|
||||
PARTICIPANT_STATUS_LABEL,
|
||||
} from '$lib/stores/eventParticipants.svelte'
|
||||
import { formatDuration, formatDateRange, dayOf } from '$lib/time'
|
||||
import Avatar from '$lib/components/Avatar.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import * as Table from '@/components/ui/table'
|
||||
import { ArrowLeft, Calendar, ChevronRight, Crown, Medal, Shield, Timer, UserCheck, Users } from 'lucide-svelte'
|
||||
|
||||
const teams = getTeamContext()
|
||||
const events = getEventContext()
|
||||
const stages = getStageContext()
|
||||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
const participants = getEventParticipantContext()
|
||||
|
||||
const team = $derived(teams.active)
|
||||
|
||||
// Gezeigt wird nur, wer zum aktiven Team gehört — `scoped` statt
|
||||
// `getById`, sonst stünde über einen geratenen Link ein fremder Fahrer da.
|
||||
const rider = $derived(riders.scoped.find((r) => r.id === page.params.riderId))
|
||||
|
||||
const account = $derived(rider?.user ? teams.users[rider.user] : null)
|
||||
|
||||
const role = $derived.by(() => {
|
||||
const uid = rider?.user
|
||||
if (!uid || !team) return null
|
||||
if (team.owner === uid) return 'owner'
|
||||
return (team.admins ?? []).includes(uid) ? 'admin' : 'mitglied'
|
||||
})
|
||||
|
||||
/**
|
||||
* Eine Zeile je Teilnahme, das jüngste Event zuerst. Gerechnet wird je
|
||||
* Event über dessen Stages: Zeiten hängen an der Stage, und nur über die
|
||||
* hängen sie am Event.
|
||||
*/
|
||||
const rows = $derived.by(() => {
|
||||
if (!rider) return []
|
||||
|
||||
const own = times.getByRider(rider.id)
|
||||
|
||||
return participants
|
||||
.byRider(rider.id)
|
||||
.map((entry) => {
|
||||
const event = events.getById(entry.event)
|
||||
const eventStages = stages.getByEvent(entry.event)
|
||||
|
||||
// Je Stage zählt die schnellste gewertete Zeit — mehrere
|
||||
// Versuche sind ein erledigter Lauf, nicht zwei.
|
||||
const best = eventStages
|
||||
.map((stage) => ({
|
||||
stage,
|
||||
time: own
|
||||
.filter((t) => t.stage === stage.id && t.status === 'finished')
|
||||
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))[0],
|
||||
}))
|
||||
.filter((s) => s.time)
|
||||
|
||||
return {
|
||||
entry,
|
||||
event,
|
||||
of: eventStages.length,
|
||||
done: best.length,
|
||||
total: best.reduce((sum, s) => sum + (s.time?.duration ?? 0), 0),
|
||||
best,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const da = dayOf(a.event?.starts)
|
||||
const db = dayOf(b.event?.starts)
|
||||
if (!da && !db) return 0
|
||||
if (!da) return 1
|
||||
if (!db) return -1
|
||||
return da < db ? 1 : da > db ? -1 : 0
|
||||
})
|
||||
})
|
||||
|
||||
const stats = $derived.by(() => {
|
||||
const all = rows.flatMap((r) => r.best)
|
||||
const durations = all.map((s) => s.time?.duration ?? 0).filter((d) => d > 0)
|
||||
|
||||
const podium = rider
|
||||
? all.filter((s) => {
|
||||
const p = times.placement(s.stage.id, rider.id)
|
||||
return p !== null && p <= 3
|
||||
}).length
|
||||
: 0
|
||||
|
||||
return {
|
||||
events: rows.length,
|
||||
confirmed: rows.filter((r) => r.entry.status === 'zugesagt').length,
|
||||
stages: all.length,
|
||||
best: durations.length > 0 ? Math.min(...durations) : null,
|
||||
podium,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{rider ? riderName(rider) || 'Fahrer' : 'Fahrer'} · Kurbeler</title></svelte:head>
|
||||
|
||||
{#if !rider}
|
||||
<Card>
|
||||
<CardContent class="py-16 text-center">
|
||||
<Users class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">Fahrer nicht gefunden</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
|
||||
</p>
|
||||
<Button href="/dashboard/team" variant="outline" class="mt-4">
|
||||
<ArrowLeft class="h-4 w-4 mr-2" />
|
||||
Zum Team
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<a
|
||||
href="/dashboard/team"
|
||||
class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2"
|
||||
>
|
||||
<ArrowLeft class="h-3 w-3" />
|
||||
Team
|
||||
</a>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Avatar user={account} size={48} />
|
||||
<h1 class="text-3xl font-bold tracking-tight">
|
||||
{riderName(rider) || 'Ohne Name'}
|
||||
</h1>
|
||||
|
||||
{#if role === 'owner'}
|
||||
<Badge class="gap-1"><Crown class="size-3" />Owner</Badge>
|
||||
{:else if role === 'admin'}
|
||||
<Badge variant="secondary" class="gap-1"><Shield class="size-3" />Admin</Badge>
|
||||
{:else if role === 'mitglied'}
|
||||
<Badge variant="outline">Mitglied</Badge>
|
||||
{/if}
|
||||
|
||||
{#if rider.user}
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
<UserCheck class="size-3" />
|
||||
kann sich anmelden
|
||||
</Badge>
|
||||
{:else}
|
||||
<span class="text-sm text-muted-foreground">kein Konto</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">Events</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold">{stats.events}</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">{stats.confirmed} zugesagt</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">Gefahrene Stages</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold">{stats.stages}</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">gewertet</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">Beste Stage-Zeit</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold tabular-nums">
|
||||
{stats.best != null ? formatDuration(stats.best) : '—'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">Podestplätze</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold">{stats.podium}</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">Top 3 einer Stage</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Calendar class="h-4 w-4 text-primary" />
|
||||
Teilnahmen ({rows.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
{#if rows.length === 0}
|
||||
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
|
||||
Dieser Fahrer ist bei keinem Event eingetragen.
|
||||
</p>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Event</Table.Head>
|
||||
<Table.Head class="w-40">Datum</Table.Head>
|
||||
<Table.Head class="w-20">Nr.</Table.Head>
|
||||
<Table.Head class="w-28">Status</Table.Head>
|
||||
<Table.Head class="w-32">Stages</Table.Head>
|
||||
<Table.Head class="w-28 text-right">Gesamtzeit</Table.Head>
|
||||
<Table.Head class="w-10"></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each rows as row (row.entry.id)}
|
||||
{@const path = `/dashboard/events/${row.entry.event}/riders/${rider.id}`}
|
||||
<Table.Row class="cursor-pointer" onclick={() => goto(path)}>
|
||||
<Table.Cell class="font-medium">
|
||||
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
|
||||
<a href={path} class="hover:underline">
|
||||
{row.event?.name || row.event?.description || 'Event'}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm text-muted-foreground">
|
||||
{formatDateRange(row.event?.starts, row.event?.ends) || '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{row.entry.number || '—'}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge
|
||||
variant={row.entry.status === 'zugesagt'
|
||||
? 'default'
|
||||
: row.entry.status === 'angefragt'
|
||||
? 'secondary'
|
||||
: 'outline'}
|
||||
>
|
||||
{PARTICIPANT_STATUS_LABEL[row.entry.status] ?? row.entry.status}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm text-muted-foreground">
|
||||
<!-- Ohne Stages gibt es nichts zu zählen; „0 von 0" wäre nur Lärm. -->
|
||||
{row.of === 0 ? '—' : `${row.done} von ${row.of} Stages`}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right tabular-nums">
|
||||
{row.done > 0 ? formatDuration(row.total) : '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<ChevronRight class="h-4 w-4 text-muted-foreground" />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -7,14 +7,23 @@
|
|||
* keine Einstellung, sondern der Gegenstand der Anwendung.
|
||||
*/
|
||||
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 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Einstellungen</title></svelte:head>
|
||||
<svelte:head><title>Einstellungen · Kurbeler</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Einstellungen</h1>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Worüber die App per Mail berichtet.
|
||||
*
|
||||
* Es gibt genau eine Mail am Tag, morgens um sechs, mit allem, was seit
|
||||
* der letzten passiert ist — nicht eine Mail je Ereignis. Das steht hier
|
||||
* ausdrücklich, weil sonst niemand einen Schalter umlegt: Wer „neuer
|
||||
* Kommentar" liest, rechnet mit einer Mail je Kommentar und lässt es
|
||||
* lieber aus.
|
||||
*
|
||||
* Gespeichert wird sofort beim Umlegen, ohne Knopf — wie Name und Bild im
|
||||
* Profil. Der Datensatz entsteht dabei erst beim ersten Mal; bis dahin
|
||||
* zeigen die Schalter die Voreinstellungen, und die sind kein Platzhalter,
|
||||
* sondern genau das, was der Server tut.
|
||||
*/
|
||||
import { onMount } from 'svelte'
|
||||
import {
|
||||
notificationPrefs as prefs,
|
||||
NOTIFICATION_GROUPS,
|
||||
type NotificationType,
|
||||
} from '$lib/stores/notificationPrefs.svelte'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Bell, BellOff, Clock } from 'lucide-svelte'
|
||||
|
||||
onMount(() => {
|
||||
void prefs.load()
|
||||
})
|
||||
|
||||
const off = $derived(!prefs.values.enabled)
|
||||
</script>
|
||||
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
{#if off}
|
||||
<BellOff class="size-4 text-muted-foreground" />
|
||||
{:else}
|
||||
<Bell class="size-4 text-primary" />
|
||||
{/if}
|
||||
Benachrichtigungen
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<!--
|
||||
Der Satz steht vor den Schaltern und nicht darunter: Er ist die
|
||||
Bedingung, unter der man sie überhaupt sinnvoll stellt.
|
||||
-->
|
||||
<div class="flex gap-3 rounded-lg border bg-muted/40 p-3">
|
||||
<Clock class="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Es geht <strong class="text-foreground">eine Mail am Tag</strong> raus, morgens
|
||||
gegen sechs, mit allem, was seit der letzten passiert ist. Kein Ereignis
|
||||
löst für sich eine Mail aus. Ist nichts passiert, kommt auch nichts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefs.values.enabled}
|
||||
onchange={(e) => prefs.set('enabled', e.currentTarget.checked)}
|
||||
class="mt-0.5 size-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span>
|
||||
<span class="font-medium">Tageszusammenfassung bekommen</span>
|
||||
<span class="block text-xs text-muted-foreground">
|
||||
Aus heißt: gar keine Mails, unabhängig von allem darunter. Für die
|
||||
zwei Wochen, in denen Ruhe sein soll, ohne danach alles neu
|
||||
zusammenzuklicken.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{#if prefs.error}
|
||||
<p class="text-sm text-destructive">{prefs.error}</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!--
|
||||
Bei abgeschalteter Zusammenfassung bleiben die Schalter bedienbar, aber
|
||||
sichtbar wirkungslos. Sie auszublenden hieße, jemandem seine eigenen
|
||||
Einstellungen wegzunehmen, während er sie nur pausiert hat.
|
||||
-->
|
||||
<div class:opacity-50={off} class="space-y-6 transition-opacity">
|
||||
{#each NOTIFICATION_GROUPS as group (group.title)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">{group.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
{#each group.items as item, i (item.key)}
|
||||
{#if i > 0}
|
||||
<Separator />
|
||||
{/if}
|
||||
<label class="flex items-start gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefs.values[item.key as NotificationType]}
|
||||
onchange={(e) =>
|
||||
prefs.set(item.key, e.currentTarget.checked)}
|
||||
class="mt-0.5 size-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span>
|
||||
<span class="font-medium">{item.label}</span>
|
||||
<span class="block text-xs text-muted-foreground">{item.hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
245
frontend/src/routes/dashboard/settings/users/+page.svelte
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Das Benutzerverzeichnis — alle Konten der App, nur für Superadmins.
|
||||
*
|
||||
* Warum hier und nirgends sonst: Ein Konto gehört keinem Team. Die
|
||||
* Teamleitung verwaltet Fahrer und Mitgliedschaften und entzieht im Kader
|
||||
* höchstens den Zugang; an ein fremdes Konto kommt sie nicht heran, und
|
||||
* `users.deleteRule` erzwingt das auch serverseitig. Bleibt der Fall, den
|
||||
* bis hierher niemand erledigen konnte: ein Konto, das niemand mehr
|
||||
* braucht und dessen Inhaber sich nicht selbst darum kümmert.
|
||||
*
|
||||
* Die Liste kommt aus `/api/directory/users` und nicht aus der Collection:
|
||||
* Konten entstehen mit `emailVisibility: false`, die Adresse steht in
|
||||
* keinem `users`-Datensatz, den jemand anderes liest. Der Hook gibt sie
|
||||
* dem Superadmin heraus — und nur ihm.
|
||||
*/
|
||||
import { api, auth } from '$lib/stores/pocketbase.svelte'
|
||||
import { app, tooltip } from '$lib/stores/app.svelte'
|
||||
import { formatDay } from '$lib/time'
|
||||
import Avatar from '$lib/components/Avatar.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import * as Table from '@/components/ui/table'
|
||||
import { Crown, Search, ShieldAlert, Trash2, Users } from 'lucide-svelte'
|
||||
|
||||
type DirectoryUser = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
verified: boolean
|
||||
superadmin: boolean
|
||||
created: string
|
||||
avatar: string
|
||||
teams: string[]
|
||||
owns: string[]
|
||||
}
|
||||
|
||||
const allowed = $derived(!!auth.user?.superadmin)
|
||||
|
||||
let records = $state<DirectoryUser[]>([])
|
||||
let loading = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let query = $state('')
|
||||
|
||||
async function load() {
|
||||
if (!allowed) return
|
||||
|
||||
loading = true
|
||||
error = null
|
||||
try {
|
||||
const res = await api.send('/api/directory/users', { method: 'GET' })
|
||||
records = (res?.users ?? []) as DirectoryUser[]
|
||||
} catch (e: any) {
|
||||
error = e.message ?? 'Das Verzeichnis konnte nicht geladen werden.'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (allowed && records.length === 0 && !loading && !error) void load()
|
||||
})
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = query.toLowerCase().trim()
|
||||
const list = q
|
||||
? records.filter(
|
||||
(u) =>
|
||||
u.name.toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
u.teams.some((t) => t.toLowerCase().includes(q)),
|
||||
)
|
||||
: records
|
||||
|
||||
return [...list].sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email))
|
||||
})
|
||||
|
||||
/**
|
||||
* Warum dieses Konto nicht fallen darf — leer heißt: es darf.
|
||||
*
|
||||
* Das eigene steht bewusst nicht zur Wahl: Es geht, aber nicht von hier
|
||||
* aus. Wer sich selbst löscht, meldet sich damit ab und verliert mitten
|
||||
* in der Verwaltung den Boden; dafür gibt es den Weg über das eigene
|
||||
* Profil, der genau das erwartet.
|
||||
*
|
||||
* Ein Team-Owner geht gar nicht. `teams.owner` ist ein Pflichtfeld ohne
|
||||
* Kaskade — PocketBase müsste es beim Löschen leeren und bräche dabei den
|
||||
* Datensatz. Der Versuch endete in einem Datenbankfehler, den niemand
|
||||
* deuten kann, deshalb steht die Bedingung hier vorne und im Klartext.
|
||||
*/
|
||||
function blockedReason(u: DirectoryUser): string {
|
||||
if (u.id === auth.user?.id) {
|
||||
return 'Das eigene Konto löschst du in deinem Profil.'
|
||||
}
|
||||
if (u.owns.length > 0) {
|
||||
const list = u.owns.join('“, „')
|
||||
return `Besitzt das Team „${list}“. Erst das Team übergeben oder löschen.`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function remove(u: DirectoryUser) {
|
||||
if (blockedReason(u)) return
|
||||
|
||||
app.confirm.request({
|
||||
title: 'Konto löschen?',
|
||||
text:
|
||||
`Das Konto von „${u.name || u.email}“ wird endgültig gelöscht. ` +
|
||||
`Fahrer, Teilnahmen und Zeiten bleiben in ihren Teams stehen — ` +
|
||||
`sie gehören dem Team und nicht dem Login. Über einen Einladungslink ` +
|
||||
`kann sich die Person ein neues Konto anlegen.`,
|
||||
yes: async () => {
|
||||
try {
|
||||
await api.collection('users').delete(u.id)
|
||||
records = records.filter((r) => r.id !== u.id)
|
||||
app.confirm.close()
|
||||
} catch (e: any) {
|
||||
app.confirm.close()
|
||||
error = e.message ?? 'Das Konto konnte nicht gelöscht werden.'
|
||||
}
|
||||
},
|
||||
no: () => app.confirm.close(),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !allowed}
|
||||
<Card>
|
||||
<CardContent class="py-16 text-center">
|
||||
<ShieldAlert class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">Nur für Superadmins</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Diese Seite führt alle Konten der App. Die Rolle wird direkt in PocketBase vergeben.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Users class="size-4 text-primary" />
|
||||
Benutzer
|
||||
<span class="text-muted-foreground font-normal">({records.length})</span>
|
||||
</CardTitle>
|
||||
|
||||
<div class="relative w-full max-w-xs">
|
||||
<Search
|
||||
class="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input bind:value={query} placeholder="Name, E-Mail oder Team" class="pl-8" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="p-0">
|
||||
{#if error}
|
||||
<p class="px-6 pb-4 text-sm text-destructive">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading && records.length === 0}
|
||||
<p class="px-6 py-8 text-center text-sm text-muted-foreground">Wird geladen …</p>
|
||||
{:else if filtered.length === 0}
|
||||
<p class="px-6 py-8 text-center text-sm text-muted-foreground">
|
||||
{records.length === 0 ? 'Keine Konten.' : 'Keine Treffer.'}
|
||||
</p>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head class="w-64">E-Mail</Table.Head>
|
||||
<Table.Head>Teams</Table.Head>
|
||||
<Table.Head class="w-28">Angelegt</Table.Head>
|
||||
<Table.Head class="w-16 text-right">Aktion</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each filtered as u (u.id)}
|
||||
{@const blocked = blockedReason(u)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<span class="flex items-center gap-2">
|
||||
<!-- collectionName ergaenzt, damit getFileURL das Bild findet:
|
||||
der Hook liefert einen schlanken Datensatz, keinen Record. -->
|
||||
<Avatar user={{ ...u, collectionName: 'users' }} size={28} />
|
||||
{u.name || '— ohne Name —'}
|
||||
{#if u.superadmin}
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
<Crown class="size-3" />
|
||||
Superadmin
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if !u.verified}
|
||||
<Badge variant="outline">unbestätigt</Badge>
|
||||
{/if}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell class="text-sm text-muted-foreground">
|
||||
<span class="block truncate">{u.email}</span>
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell class="text-sm text-muted-foreground">
|
||||
{#if u.teams.length === 0}
|
||||
<span class="italic">in keinem Team</span>
|
||||
{:else}
|
||||
{u.teams.join(', ')}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell class="text-sm text-muted-foreground">
|
||||
{formatDay(u.created) || '—'}
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell class="text-right">
|
||||
{#if blocked}
|
||||
<!-- Kein abgeschalteter Knopf ohne Begruendung: Der Grund
|
||||
haengt am Zeichen, sonst raet man, warum nichts geht. -->
|
||||
<span class="inline-flex" use:tooltip={{ content: blocked }}>
|
||||
<Button variant="ghost" size="icon" disabled>
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
{:else}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
tooltip="Konto löschen"
|
||||
onclick={() => remove(u)}
|
||||
>
|
||||
<Trash2 class="size-4 text-destructive" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
|
@ -20,13 +20,13 @@
|
|||
* auch die Teamleitung nicht heran. Beim Anlegen setzt riders.createLogin
|
||||
* 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<RidersResponse | null>(null)
|
||||
let editingUser = $state<string | null>(null)
|
||||
let form = $state({ name: '', withAccount: false, email: '', password: '', admin: false })
|
||||
let form = $state({ name: '', withAccount: false, email: '', admin: false })
|
||||
let busy = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
/** Rolle der gerade bearbeiteten Person, mitlaufend mit dem Team. */
|
||||
/** Rolle des gerade bearbeiteten Fahrers, mitlaufend mit dem Team. */
|
||||
const editingRole = $derived(roleOf(editingUser ?? undefined))
|
||||
|
||||
/** 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,28 +183,21 @@
|
|||
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.'
|
||||
if (form.withAccount && !form.email.trim()) {
|
||||
error = 'Für die Einladung braucht es eine E-Mail-Adresse.'
|
||||
return
|
||||
}
|
||||
if (form.password.length < 8) {
|
||||
error = 'Das Passwort braucht mindestens 8 Zeichen.'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
busy = true
|
||||
error = null
|
||||
|
|
@ -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<RidersResponse | null>(null)
|
||||
let accountForm = $state({ email: '', password: '' })
|
||||
let accountForm = $state({ email: '', days: 14 })
|
||||
let accountBusy = $state(false)
|
||||
let accountError = $state<string | null>(null)
|
||||
let accountSent = $state<string | null>(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
|
||||
|
||||
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) {
|
||||
alert(e.message ?? 'Das Mitglied konnte nicht entfernt werden.')
|
||||
app.confirm.close()
|
||||
alert(e.message ?? 'Der Zugang konnte nicht entzogen werden.')
|
||||
}
|
||||
},
|
||||
no: () => app.confirm.close(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Einladungslinks -------------------------------------------------
|
||||
|
|
@ -419,7 +450,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Team</title></svelte:head>
|
||||
<svelte:head><title>Team · Kurbeler</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
|
|
@ -517,7 +548,7 @@
|
|||
{#if canManage}
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="size-4 mr-2" />
|
||||
Person anlegen
|
||||
Fahrer anlegen
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -530,7 +561,7 @@
|
|||
<p class="font-medium">Noch niemand im Team</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
{#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 @@
|
|||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head class="w-48">Konto</Table.Head>
|
||||
<Table.Head class="w-32">Rolle</Table.Head>
|
||||
<Table.Head class="w-24">Events</Table.Head>
|
||||
<Table.Head class="w-24 text-right">Zeiten</Table.Head>
|
||||
{#if canManage}
|
||||
<Table.Head class="text-right">Aktionen</Table.Head>
|
||||
{/if}
|
||||
|
|
@ -555,7 +583,24 @@
|
|||
<Table.Body>
|
||||
{#each filtered as row (row.rider?.id ?? row.userId)}
|
||||
{@const path = row.rider ? riderPath(row.rider.id) : null}
|
||||
<Table.Row>
|
||||
<Table.Row
|
||||
class={path ? 'cursor-pointer' : undefined}
|
||||
onclick={path ? () => goto(path) : undefined}
|
||||
>
|
||||
<!--
|
||||
Ob jemand ein Konto hat, sagt ein
|
||||
Schlüssel am Namen — keine eigene
|
||||
Spalte.
|
||||
|
||||
Dort stand vorher in fast jeder Zeile
|
||||
dasselbe „kann sich anmelden": eine
|
||||
Spalte Breite für eine Angabe, die
|
||||
meistens zutrifft und die niemand
|
||||
vergleicht. Ein Zeichen genügt, und
|
||||
sein Fehlen sagt den anderen Fall —
|
||||
wer keins hat, dem bietet die
|
||||
Teamleitung rechts „Konto anlegen" an.
|
||||
-->
|
||||
<Table.Cell class="font-medium">
|
||||
<span class="flex items-center gap-2">
|
||||
<Avatar user={row.userId ? teams.users[row.userId] : null} size={28} />
|
||||
|
|
@ -564,35 +609,22 @@
|
|||
{:else}
|
||||
{row.name}
|
||||
{/if}
|
||||
{#if row.userId}
|
||||
<!-- Die Aktion braucht ein Element; auf einer
|
||||
Komponente ist use: nicht erlaubt. -->
|
||||
<span
|
||||
class="inline-flex shrink-0"
|
||||
use:tooltip={{ content: 'Hat ein Konto und kann sich anmelden' }}
|
||||
>
|
||||
<KeyRound class="size-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
{/if}
|
||||
{#if !row.rider}
|
||||
<Badge variant="outline">kein Fahrer</Badge>
|
||||
{/if}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
|
||||
<!--
|
||||
Hier steht bewusst KEIN zweiter Name.
|
||||
Der Name der Person steht am Fahrer,
|
||||
der Kontoname wäre eine zweite
|
||||
Wahrheit, die auseinanderläuft — und
|
||||
die niemand geradeziehen könnte:
|
||||
users.updateRule lässt nur den
|
||||
Kontoinhaber selbst ändern, kein Admin
|
||||
kommt an fremde Kontonamen heran. Das
|
||||
Konto ist deshalb nur ein Ja oder
|
||||
Nein: kann sich anmelden.
|
||||
-->
|
||||
<Table.Cell>
|
||||
{#if row.userId}
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
<UserCheck class="size-3" />
|
||||
kann sich anmelden
|
||||
</Badge>
|
||||
{:else}
|
||||
<span class="text-sm text-muted-foreground">kein Konto</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell>
|
||||
{#if row.role === 'owner'}
|
||||
<Badge class="gap-1">
|
||||
|
|
@ -611,21 +643,13 @@
|
|||
{/if}
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell class="text-sm text-muted-foreground">
|
||||
{row.rider ? participants.byRider(row.rider.id).length : '—'}
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell class="text-right text-sm text-muted-foreground">
|
||||
{row.rider ? times.getByRider(row.rider.id).length : '—'}
|
||||
</Table.Cell>
|
||||
|
||||
{#if canManage}
|
||||
<Table.Cell class="text-right whitespace-nowrap">
|
||||
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
|
||||
{#if row.rider && !row.userId}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
tooltip="Konto anlegen"
|
||||
tooltip="Per E-Mail einladen"
|
||||
onclick={() => openAccount(row.rider!)}
|
||||
>
|
||||
<KeyRound class="size-4" />
|
||||
|
|
@ -641,27 +665,26 @@
|
|||
<Pencil class="size-4" />
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Ein einziger Eingriff an einer
|
||||
fremden Person, und er nimmt
|
||||
nichts weg, was jemand
|
||||
gefahren ist. Der Owner ist
|
||||
ausgenommen: Ohne ihn hätte
|
||||
das Team niemanden mehr, der
|
||||
es löschen oder übergeben
|
||||
kann.
|
||||
-->
|
||||
{#if row.userId && row.role !== 'owner'}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
tooltip="Aus dem Team entfernen"
|
||||
onclick={() => removeFromTeam(row.userId!)}
|
||||
tooltip="Zugang zum Team entziehen"
|
||||
onclick={() => removeFromTeam(row.userId!, row.name)}
|
||||
>
|
||||
<UserMinus class="size-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if row.rider}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
tooltip="Fahrer löschen"
|
||||
onclick={() => riders.remove(row.rider!)}
|
||||
>
|
||||
<Trash2 class="size-4 text-destructive" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
</Table.Row>
|
||||
|
|
@ -675,15 +698,15 @@
|
|||
|
||||
</div>
|
||||
|
||||
<!-- Person anlegen oder bearbeiten -->
|
||||
<Dialog.Root bind:open={personDialog}>
|
||||
<!-- Fahrer anlegen oder bearbeiten -->
|
||||
<Dialog.Root bind:open={riderDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{isEdit ? 'Person bearbeiten' : 'Person anlegen'}</Dialog.Title>
|
||||
<Dialog.Title>{isEdit ? 'Fahrer bearbeiten' : 'Fahrer anlegen'}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{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.'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
|
|
@ -725,26 +748,29 @@
|
|||
{/if}
|
||||
|
||||
{#if !isEdit}
|
||||
<Separator />
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={form.withAccount}
|
||||
class="size-4 rounded border-input accent-primary"
|
||||
/>
|
||||
kann sich anmelden
|
||||
gleich per E-Mail einladen
|
||||
</label>
|
||||
|
||||
{#if form.withAccount}
|
||||
<div class="space-y-2">
|
||||
<Label for="p-mail">E-Mail</Label>
|
||||
<Input id="p-mail" type="email" bind:value={form.email} placeholder="max@example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="p-pw">Passwort</Label>
|
||||
<Input id="p-pw" type="password" bind:value={form.password} placeholder="mindestens 8 Zeichen" />
|
||||
<Input
|
||||
id="p-mail"
|
||||
type="email"
|
||||
bind:value={form.email}
|
||||
placeholder="max@example.com"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -756,8 +782,8 @@
|
|||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (personDialog = false)}>Abbrechen</Button>
|
||||
<Button onclick={savePerson} disabled={busy}>
|
||||
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
|
||||
<Button onclick={saveRider} disabled={busy}>
|
||||
{busy ? 'Wird gespeichert …' : editing ? 'Speichern' : 'Anlegen'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
|
|
@ -769,22 +795,50 @@
|
|||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
Konto für {accountRider ? riderName(accountRider) : 'Fahrer'}
|
||||
{accountRider ? riderName(accountRider) : 'Fahrer'} einladen
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
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.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if accountSent}
|
||||
<!-- Nach dem Versand steht hier nur noch, was passiert ist. Ein
|
||||
zweites Mal abschicken ginge zwar, hieße aber, dieselbe Mail
|
||||
doppelt zu verschicken, ohne dass jemand danach gefragt hat. -->
|
||||
<div class="space-y-2 py-2">
|
||||
<p class="text-sm">
|
||||
Einladung an <strong>{accountSent}</strong> ist unterwegs.
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Kommt nichts an, lohnt der Blick in den Spam-Ordner. Danach lässt sich
|
||||
hier eine neue Einladung schicken — die alte verfällt damit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button onclick={() => (accountDialog = false)}>Fertig</Button>
|
||||
</Dialog.Footer>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="a-mail">E-Mail</Label>
|
||||
<Input id="a-mail" type="email" bind:value={accountForm.email} placeholder="max@example.com" />
|
||||
<Input
|
||||
id="a-mail"
|
||||
type="email"
|
||||
bind:value={accountForm.email}
|
||||
placeholder="max@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="a-pw">Passwort</Label>
|
||||
<Input id="a-pw" type="password" bind:value={accountForm.password} placeholder="mindestens 8 Zeichen" />
|
||||
<Label for="a-days">Gültig für</Label>
|
||||
<Input id="a-days" type="number" min="0" bind:value={accountForm.days} />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Tage. 0 heißt: läuft nicht ab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if accountError}
|
||||
|
|
@ -794,10 +848,11 @@
|
|||
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (accountDialog = false)}>Abbrechen</Button>
|
||||
<Button onclick={createAccount} disabled={accountBusy}>
|
||||
{accountBusy ? 'Wird angelegt …' : 'Konto anlegen'}
|
||||
<Button onclick={sendInvite} disabled={accountBusy}>
|
||||
{accountBusy ? 'Wird verschickt …' : 'Einladung senden'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
|
|
@ -928,7 +983,7 @@
|
|||
<Dialog.Title>Logo für „{logoTeam?.name}"</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Erscheint in der Kopfzeile und blass im Seitenhintergrund. Ohne eigenes
|
||||
Logo steht dort das Wappen des Stammtischs.
|
||||
Logo steht dort die Kurbel.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,13 @@
|
|||
{@const freshComments = comments.hasRecent(trail.id)}
|
||||
<a href="/dashboard/trails/{trail.id}" class="block">
|
||||
<Card class="h-full hover:border-primary transition-colors">
|
||||
<CardHeader class="pb-3">
|
||||
<!--
|
||||
Kein zusaetzliches pb: Die Karte setzt zwischen
|
||||
Kopf und Inhalt schon ihr eigenes gap-4. Ein pb-3
|
||||
obendrauf trieb Titel und Vorschaubild sichtbar
|
||||
auseinander.
|
||||
-->
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<CardTitle class="text-base">{trail.name}</CardTitle>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
import { page } from '$app/state'
|
||||
import { goto } from '$app/navigation'
|
||||
import { auth, getFileURL } from '$lib/stores/pocketbase.svelte'
|
||||
import { publicInvite, type InviteTeam } from '$lib/stores/teamInvites.svelte'
|
||||
import { publicInvite, type InviteTeam, type InviteRecipient } from '$lib/stores/teamInvites.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
|
@ -31,13 +31,31 @@
|
|||
let team = $state<InviteTeam | null>(null)
|
||||
let loadError = $state<string | null>(null)
|
||||
|
||||
/**
|
||||
* Für wen der Link gilt. Ein Einladungslink gilt jedem — dann steht hier
|
||||
* `personal: false` und das Formular ist leer wie bisher. Eine
|
||||
* persönliche Einladung ging an genau eine Adresse; die steht schon drin
|
||||
* und lässt sich nicht ändern.
|
||||
*/
|
||||
let recipient = $state<InviteRecipient | null>(null)
|
||||
|
||||
let form = $state({ name: '', email: '', password: '', passwordConfirm: '' })
|
||||
let busy = $state(false)
|
||||
let error = $state<string | null>(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 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Einladung</title></svelte:head>
|
||||
<svelte:head><title>Einladung · Kurbeler</title></svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<Card class="w-full max-w-md">
|
||||
|
|
@ -123,6 +141,9 @@
|
|||
<CardDescription>
|
||||
{#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 @@
|
|||
|
||||
<div class="space-y-2">
|
||||
<Label for="i-mail">E-Mail</Label>
|
||||
<!--
|
||||
Bei einer persönlichen Einladung steht die Adresse
|
||||
fest: An sie ging die Mail, und nur für sie gilt der
|
||||
Token. Das Feld ist deshalb gesperrt — dass ein
|
||||
gesperrtes Feld nur eine Bitte ist, weiß der Hook und
|
||||
prüft es beim Anlegen noch einmal.
|
||||
-->
|
||||
<Input
|
||||
id="i-mail"
|
||||
type="email"
|
||||
bind:value={form.email}
|
||||
disabled={busy}
|
||||
disabled={busy || !!recipient?.personal}
|
||||
readonly={!!recipient?.personal}
|
||||
autocomplete="email"
|
||||
placeholder="max@example.com"
|
||||
required
|
||||
/>
|
||||
{#if recipient?.personal}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
An diese Adresse ging die Einladung.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Login</title></svelte:head>
|
||||
<svelte:head><title>Kurbeler</title></svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<Card class="w-full max-w-md">
|
||||
<CardHeader class="justify-items-center gap-3 pt-2 text-center">
|
||||
<Logo class="h-20 w-20" />
|
||||
<CardTitle><Wordmark class="h-11" /></CardTitle>
|
||||
<CardDescription>Melde dich an, um fortzufahren</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
|
|||
BIN
frontend/static/kurbeler-icon-192.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
frontend/static/kurbeler-icon-512.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
1
frontend/static/kurbeler-icon-maskable.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" fill="#eab308"/><g transform="translate(13.2455 12.1601) scale(0.62)"><path fill="#18181b" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 789 B |
1
frontend/static/kurbeler-icon.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#eab308"/><g transform="translate(6.5906 5.1201) scale(0.84)"><path fill="#18181b" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 795 B |
1
frontend/static/kurbeler-logo-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 263.67 64" width="263.67" height="64"><path fill="#eab308" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/><g transform="translate(58.638 -5.881) scale(0.40250)"><path fill="#fafaf9" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
1
frontend/static/kurbeler-logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 263.67 64" width="263.67" height="64"><path fill="#eab308" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/><g transform="translate(58.638 -5.881) scale(0.40250)"><path fill="#18181b" d="M 17.045455,140 V 46.909091 h 22.5 v 39.363636 h 1.227272 L 71.545455,46.909091 h 26.5 l -33,41.545454 L 98.636364,140 H 71.727273 l -23.181818,-36.59091 -9,11.36364 V 140 Z M 144.59091,109.86364 V 70.181818 h 22.18182 V 140 h -21.18182 v -13 h -0.72727 q -2.31819,6.40909 -7.90909,10.18182 -5.54546,3.72727 -13.4091,3.72727 -7.13636,0 -12.54545,-3.27273 -5.40909,-3.27272 -8.40909,-9.13636 -3.000001,-5.90909 -3.045455,-13.81818 V 70.181818 h 22.227275 v 40.136362 q 0.0455,5.68182 3,8.95455 2.95454,3.27272 8.04545,3.27272 3.31818,0 5.95455,-1.45454 2.68182,-1.5 4.22727,-4.31818 1.59091,-2.86364 1.59091,-6.90909 z M 177.90898,140 V 70.181818 h 21.59091 v 12.727273 h 0.72727 Q 202.13625,76 206.45443,72.636364 q 4.31819,-3.409091 10.04546,-3.409091 1.54545,0 3.18182,0.227272 1.63636,0.181819 3.04545,0.59091 V 89.363636 Q 221.13625,88.818182 218.54534,88.5 q -2.54545,-0.318182 -4.54545,-0.318182 -3.95455,0 -7.13636,1.772727 -3.13637,1.727273 -4.95455,4.863637 -1.77273,3.090909 -1.77273,7.272728 V 140 Z m 50.90918,0 V 46.909091 h 22.22727 v 35.227273 h 0.45455 Q 252.86361,78.954545 255.36361,76 q 2.54546,-2.954545 6.45455,-4.818182 3.95455,-1.909091 9.45455,-1.909091 7.27272,0 13.5909,3.818182 6.36364,3.818182 10.27273,11.772727 3.90909,7.954546 3.90909,20.272724 0,11.86364 -3.77272,19.86364 -3.72728,8 -10.09091,12 -6.31819,4 -14.04546,4 -5.27273,0 -9.13636,-1.72727 -3.86364,-1.72728 -6.5,-4.54546 -2.59091,-2.81818 -4,-6.04545 h -0.68182 V 140 Z m 21.77273,-34.90909 q 0,5.63636 1.5,9.81818 1.54545,4.18182 4.40909,6.5 2.90909,2.27273 6.95454,2.27273 4.09091,0 6.95455,-2.27273 2.86364,-2.31818 4.31818,-6.5 1.5,-4.18182 1.5,-9.81818 0,-5.636365 -1.5,-9.772728 -1.45454,-4.136364 -4.31818,-6.409091 -2.81818,-2.272727 -6.95455,-2.272727 -4.09091,0 -6.95454,2.227272 -2.86364,2.227273 -4.40909,6.363637 -1.5,4.136363 -1.5,9.863637 z m 89.77281,36.22727 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 312.81825 V 97.545455 h 40 Q 352.77279,94 351.13643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 404.1365,46.909091 V 140 H 381.90922 V 46.909091 Z m 43.7272,94.409089 q -10.95454,0 -18.90909,-4.31818 -7.90909,-4.36364 -12.18182,-12.40909 -4.22727,-8.09091 -4.22727,-19.22727 0,-10.818185 4.27273,-18.909095 4.27273,-8.136363 12.04545,-12.636363 7.77273,-4.545455 18.31818,-4.545455 7.45455,0 13.63637,2.318182 6.18182,2.318182 10.68182,6.863636 4.5,4.545455 7,11.227273 2.5,6.636364 2.5,15.227272 v 5.54546 H 420.31825 V 97.545455 h 40 Q 460.27279,94 458.63643,91.227273 q -1.63636,-2.772728 -4.5,-4.318182 -2.81818,-1.590909 -6.5,-1.590909 -3.72727,0 -6.68182,1.681818 -2.95454,1.636364 -4.68182,4.5 -1.72727,2.818182 -1.81818,6.409091 v 13.136359 q 0,4.27273 1.68182,7.5 1.68182,3.18182 4.77273,4.95455 3.09091,1.77273 7.36363,1.77273 2.95455,0 5.36364,-0.81818 2.40909,-0.81819 4.13636,-2.4091 1.72728,-1.5909 2.59091,-3.90909 l 20.40909,0.59091 q -1.27272,6.86364 -5.59091,11.95455 -4.27272,5.04545 -11.22727,7.86363 -6.95454,2.77273 -16.09091,2.77273 z M 489.40922,140 V 70.181818 h 21.59091 v 12.727273 h 0.72728 Q 513.6365,76 517.95468,72.636364 q 4.31818,-3.409091 10.04545,-3.409091 1.54546,0 3.18182,0.227272 1.63636,0.181819 3.04546,0.59091 V 89.363636 Q 532.6365,88.818182 530.04559,88.5 q -2.54546,-0.318182 -4.54546,-0.318182 -3.95454,0 -7.13636,1.772727 -3.13636,1.727273 -4.95455,4.863637 -1.77272,3.090909 -1.77272,7.272728 V 140 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
1
frontend/static/kurbeler-mark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><g transform="translate(1.7508 0.0002) scale(1.0)"><path fill="currentColor" d="M 45.183594 7.5039062 A 5.5 5.5 0 0 0 42.220703 8.2539062 L 19.5 21.5625 L 19.5 15.25 A 4.75 4.75 0 0 0 14.75 10.5 A 4.75 4.75 0 0 0 10 15.25 L 10 48.75 A 4.75 4.75 0 0 0 14.75 53.5 A 4.75 4.75 0 0 0 19.5 48.75 L 19.5 42.4375 L 42.220703 55.746094 A 5.5 5.5 0 0 0 48.601562 46.84375 L 31.476562 32 L 48.601562 17.15625 A 5.5 5.5 0 0 0 45.183594 7.5039062 zM14.100 32.000A4.4 4.4 0 1 1 22.900 32.000A4.4 4.4 0 1 1 14.100 32.000ZM42.300 13.000A2.7 2.7 0 1 1 47.700 13.000A2.7 2.7 0 1 1 42.300 13.000ZM42.300 51.000A2.7 2.7 0 1 1 47.700 51.000A2.7 2.7 0 1 1 42.300 51.000Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 746 B |
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "Stammtisch Hersbruck",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 55 KiB |
1
frontend/static/stammtisch-hersbruck.svg
Normal file
|
After Width: | Height: | Size: 47 KiB |