feat: Logo pro Team, auch als Wasserzeichen im Seitenhintergrund

teams.logo nimmt eine Datei auf; hochgeladen wird sie in den
Team-Einstellungen. Ohne eigenes Logo bleibt das Wappen aus static/ als
Rueckfalloption - fuer den Stammtisch Hersbruck aendert sich damit nichts,
fuer jedes weitere Team waere es fremdes Vereinswappen gewesen.

Dasselbe Logo liegt gedreht und sehr blass in der rechten unteren Ecke der
Seite. Es haengt fest im Bildschirm, faengt keine Klicks ab und ist fuer
Screenreader nicht vorhanden; im Dunkelmodus etwas kraeftiger, sonst
verschwaende es im dunklen Grund.

In TODO.md ausserdem der Plan fuer Ausfahrten: Sie werden kein zweites
Konzept neben Events, sondern Schalter daran - timing, participation
(offen/bestaetigung/geschlossen) und Serien, die echte Einzeltermine
erzeugen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
This commit is contained in:
Daniel Michelberger 2026-09-02 15:42:04 +02:00
parent fb2652dcbf
commit 4fa0de8753
8 changed files with 250 additions and 21 deletions

43
TODO.md
View file

@ -36,6 +36,39 @@ Ein **bestehendes** fremdes Konto in ein Team zu holen braucht eine offenere
`listRule` (z. B. `@request.auth.id != ""`). Das erlaubt allerdings, alle
Konten der Instanz aufzulisten — deshalb bewusst nicht ohne Rücksprache.
### Ausfahrten: geplant, noch nicht gebaut
Ausfahrten werden **nicht** zu einem zweiten Konzept neben Events, sondern
Events bekommen Schalter. Ein Bikepark-Ausflug mit gestoppten Läufen ist beides
zugleich; eine Trennung wäre willkürlich und würde Datum, Rechte und Oberfläche
doppelt führen.
Geplant, in dieser Reihenfolge:
1. **`events.timing`** (bool). Runs und Zeitnahme erscheinen nur, wenn gesetzt.
Der wöchentliche Stammtisch braucht sie nicht.
2. **Teilnahme** als eigene Collection `event_participants` (Event, Person,
Status, Zeitpunkt) — nicht als Liste am Event, sonst hängen Status und
Zeitpunkt nirgends, und Gäste ohne Konto passen nicht hinein.
`events.participation` steuert, wie man hineinkommt:
| Wert | Bedeutung |
|---|---|
| `offen` | Jedes Teammitglied trägt sich selbst ein |
| `bestaetigung` | Anmeldung möglich, Ersteller oder Team-Admin bestätigt |
| `geschlossen` | Nur Ersteller oder Team-Admin trägt Teilnehmer ein |
3. **Serien** (`event_series`): Regel, Zeitraum und Vorlage; daraus entstehen
echte Einzeltermine. Bewusst nicht ein Datensatz mit Wiederholungsregel, aus
dem Termine nur gerechnet werden — sobald „wer kommt am 12.?" oder „diese
Woche fällt aus" beantwortet werden soll, braucht jeder Termin einen eigenen
Datensatz.
Offen: Sind Teilnehmer **Nutzerkonten** oder **Fahrer**? Für den Stammtisch
wären es Konten, für eine gemeinsame Ausfahrt eher Fahrer (die es auch ohne
Konto gibt). Beides zugleich geht, macht die Collection aber breiter.
## Vor dem nächsten Deploy
### `PUBLIC_CARTO_API_KEY` in Coolify setzen
@ -50,13 +83,14 @@ Kacheln ohne Schlüssel weiter — die Karte bleibt also sichtbar.
### Migrationen greifen beim nächsten Start
Fünf neue Migrationen liegen in `backend/pb_migrations/`:
Sechs neue Migrationen liegen in `backend/pb_migrations/`:
- `1754500500_riders_user_and_admin_rules.js`
- `1754500600_events_dates_instead_of_status.js`
- `1754500700_trail_status_meta.js`
- `1754500800_trail_markers_custom.js`
- `1754500900_users_superadmin.js`
- `1754501000_teams_logo.js`
Solange sie nicht gelaufen sind, scheitert alles, was auf den neuen Regeln
aufbaut. Am deutlichsten beim Anlegen eines Mitglieds oder eines Fahrer-Logins:
@ -85,6 +119,13 @@ cd frontend && npm run generate-pocketbase-types
Die Datei sollte danach unverändert bleiben. Tut sie es nicht, gilt das Ergebnis
des Generators.
### Logo des Stammtischs zuordnen
`teams.logo` gibt es ab `1754501000`. Solange nichts hinterlegt ist, zeigt die
App weiterhin das Wappen aus `static/` — für den Stammtisch Hersbruck sieht es
also unverändert aus. Wer es als Datei am Team haben will, lädt es nach dem
Deploy unter Einstellungen → Team → Logo hoch.
## Kleinigkeiten
- Der Melder eines Trail-Markers („wer") zeigt bis zum Deploy „Unbekannt". Die

View file

@ -334,6 +334,27 @@ migrate((app) => {
"system": false,
"type": "relation"
},
{
"help": "",
"hidden": false,
"id": "file_team_logo",
"maxSelect": 1,
"maxSize": 5242880,
"mimeTypes": [
"image/jpeg",
"image/png",
"image/svg+xml",
"image/gif",
"image/webp"
],
"name": "logo",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": null,
"type": "file"
},
{
"hidden": false,
"id": "autodate2990389176",

View file

@ -0,0 +1,38 @@
/// <reference path="../pb_data/types.d.ts" />
// Jedes Team bringt sein eigenes Logo mit.
//
// Bisher steckte das Wappen fest in der Anwendung (static/schildl.svg und
// static/radl.svg). Für den Stammtisch Hersbruck stimmt das, für jedes
// weitere Team wäre es fremdes Vereinswappen. Ohne hinterlegte Datei bleibt
// die bisherige Darstellung als Rückfalloption bestehen.
//
// 5 MB, weil die vorhandenen Wappen als Inkscape-SVG je rund 2,5 MB wiegen.
migrate((app) => {
const teams = app.findCollectionByNameOrId('teams')
if (!teams.fields.getByName('logo')) {
teams.fields.add(new Field({
id: 'file_team_logo',
name: 'logo',
type: 'file',
maxSelect: 1,
maxSize: 5242880,
mimeTypes: [
'image/jpeg',
'image/png',
'image/svg+xml',
'image/gif',
'image/webp',
],
}))
}
app.save(teams)
}, (app) => {
const teams = app.findCollectionByNameOrId('teams')
teams.fields.removeByName('logo')
app.save(teams)
})

View file

@ -1,10 +1,28 @@
<script lang="ts">
// Groesse ueber eine Klasse steuerbar, damit dasselbe Logo im Header
// (klein) und auf der Login-Karte (gross) verwendet werden kann.
let { class: className = 'w-24 h-24' }: { class?: string } = $props()
/**
* 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.
*
* Groesse ueber eine Klasse steuerbar, damit dasselbe Logo im Header
* (klein) und auf der Login-Karte (gross) verwendet werden kann.
*/
import { getFileURL } from '$lib/stores/pocketbase.svelte'
import type { TeamsResponse } from '$lib/types'
let {
class: className = 'w-24 h-24',
team = null,
}: { class?: string; team?: TeamsResponse | null } = $props()
const url = $derived(team?.logo ? getFileURL(team, team.logo) : null)
</script>
<div class="relative {className}" id="logo">
{#if url}
<img alt="" class="absolute inset-0 h-full w-full object-contain" src={url} />
{:else}
<img alt="" class="absolute inset-0 h-full w-full object-contain" src="/schildl.svg">
<img alt="" class="absolute inset-0 h-full w-full scale-110 object-contain" src="/radl.svg">
{/if}
</div>

View file

@ -112,6 +112,20 @@ export class TeamStore {
return await api.collection('teams').update(id, { name })
}
/**
* Logo setzen oder entfernen. Mit Datei als FormData ein leeres Feld im
* FormData würde ein vorhandenes Bild löschen, deshalb der zweite Weg.
*/
async setLogo(id: string, file: File | null) {
if (!file) {
return await api.collection('teams').update(id, { logo: null })
}
const data = new FormData()
data.append('logo', file)
return await api.collection('teams').update(id, data)
}
/** Mitglied hinzufügen (Owner/Admin only) */
async addMember(teamId: string, userId: string) {
const team = this.records.find((t) => t.id === teamId)

View file

@ -86,6 +86,7 @@ export type TeamsRecord = {
admins?: RecordIdString[]
created?: IsoDateString
id: string
logo?: string
name: string
owner: RecordIdString
updated?: IsoDateString

View file

@ -101,15 +101,28 @@
</script>
{#if auth.user}
<div class="min-h-screen bg-background">
<header class="border-b bg-card">
<div class="relative min-h-screen bg-background">
<!--
Das Logo als Wasserzeichen: fest in der Ecke, gedreht und sehr
blass. aria-hidden und pointer-events-none, damit es weder
vorgelesen wird noch Klicks abfängt. Im Dunkelmodus etwas
kräftiger, sonst verschwindet es im dunklen Grund.
-->
<div
aria-hidden="true"
class="pointer-events-none fixed -bottom-24 -right-24 z-0 select-none opacity-[0.04] dark:opacity-[0.07]"
>
<Logo team={teams.active} class="size-[32rem] max-w-[70vw] rotate-12" />
</div>
<header class="relative z-10 border-b bg-card">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex min-h-16 items-center justify-between gap-4 py-2">
<!-- Links: Logo und Menü. Das Logo fuellt seine Box, in
voller Groesse wuerde es die 64 px hohe Leiste
ueberragen und beschnitten wirken. -->
<div class="flex items-center gap-3 min-w-0">
<Logo class="h-11 w-11 shrink-0" />
<Logo team={teams.active} class="h-11 w-11 shrink-0" />
<nav class="hidden md:flex items-center gap-1">
{#each nav as item}
@ -233,7 +246,7 @@
{/if}
</header>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<main class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{@render children?.()}
</main>

View file

@ -13,7 +13,8 @@
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import Avatar from '$lib/components/Avatar.svelte'
import { Crown, LogOut, Plus, Shield, Trash2, UserMinus, UserPlus, Users } from 'lucide-svelte'
import Logo from '$lib/components/Logo.svelte'
import { Crown, Image, LogOut, Plus, Shield, Trash2, UserMinus, UserPlus, Users } from 'lucide-svelte'
import type { TeamsResponse } from '$lib/types'
const teams = getTeamContext()
@ -82,6 +83,35 @@
}
}
// ---- Logo ----
let logoDialog = $state(false)
let logoTeam = $state<TeamsResponse | null>(null)
let logoFile = $state<File | null>(null)
let logoBusy = $state(false)
let logoError = $state<string | null>(null)
function openLogo(t: TeamsResponse) {
logoTeam = t
logoFile = null
logoError = null
logoDialog = true
}
async function saveLogo(file: File | null) {
if (!logoTeam) return
logoBusy = true
logoError = null
try {
await teams.setLogo(logoTeam.id, file)
logoDialog = false
} catch (e: any) {
logoError = e.message ?? 'Das Logo konnte nicht gespeichert werden.'
} finally {
logoBusy = false
}
}
// ---- Mitglied anlegen ----
// Bestehende Konten lassen sich nicht suchen: users.listRule gibt nur den
// eigenen Datensatz frei, und das soll auch so bleiben. Ein neues Konto
@ -175,6 +205,8 @@
<Card>
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="flex items-center gap-3">
<Logo team={t} class="size-10 shrink-0" />
<div>
<CardTitle class="flex items-center gap-2">
{t.name}
@ -186,6 +218,7 @@
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
</CardDescription>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
{#if t.id !== teams.activeId}
<Button variant="outline" size="sm" onclick={() => teams.setActive(t.id)}>
@ -196,6 +229,10 @@
<Button variant="outline" size="sm" onclick={() => openRename(t)}>
Umbenennen
</Button>
<Button variant="outline" size="sm" onclick={() => openLogo(t)}>
<Image class="size-4 mr-1" />
Logo
</Button>
<Button variant="outline" size="sm" onclick={() => openMember(t)}>
<UserPlus class="size-4 mr-1" />
Mitglied
@ -345,3 +382,49 @@
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Logo -->
<Dialog.Root bind:open={logoDialog}>
<Dialog.Content>
<Dialog.Header>
<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.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="flex items-center gap-4">
<Logo team={logoTeam} class="size-20 shrink-0" />
<div class="space-y-1 min-w-0">
<Label for="team-logo">Datei</Label>
<input
id="team-logo"
type="file"
accept="image/*"
class="block w-full text-sm"
onchange={(e) => (logoFile = e.currentTarget.files?.[0] ?? null)}
/>
<p class="text-xs text-muted-foreground">PNG, SVG oder WebP, bis 5 MB.</p>
</div>
</div>
{#if logoError}
<p class="text-sm text-destructive">{logoError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (logoDialog = false)}>Abbrechen</Button>
{#if logoTeam?.logo}
<Button variant="outline" disabled={logoBusy} onclick={() => saveLogo(null)}>
Entfernen
</Button>
{/if}
<Button disabled={!logoFile || logoBusy} onclick={() => saveLogo(logoFile)}>
{logoBusy ? 'Wird gespeichert …' : 'Speichern'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>