feat: Erzeugte Avatare und Verfasser an Kommentaren

Ohne hinterlegtes Bild stehen Initialen auf einem Farbton, der sich aus der
User-ID errechnet: Derselbe Mensch bekommt ueberall dasselbe Bild, ohne dass
es irgendwo gespeichert werden muss. Bewusst kein externer Avatar-Dienst -
der wuerde bei jedem Seitenaufruf verraten, wer hier unterwegs ist.

Kommentare zeigen jetzt ihren Verfasser, aber nur als Bild; der Name steht
im Tooltip. In einer schmalen Spalte neben der Karte ist fuer mehr kein
Platz.

Verwendet wird der Avatar ausserdem in der Kopfzeile, im Profil und in der
Mitgliederliste.

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 13:13:22 +02:00
parent fe8e301e65
commit 27c83946df
4 changed files with 97 additions and 16 deletions

View file

@ -0,0 +1,78 @@
<script lang="ts">
/**
* Bild eines Nutzers — und wenn keins hinterlegt ist, ein erzeugtes aus
* Initialen und einer Farbe.
*
* Die Farbe kommt aus einer Prüfsumme über die ID: Derselbe Mensch bekommt
* damit überall dasselbe Bild, ohne dass es irgendwo gespeichert werden
* müsste. Bewusst kein externer Avatar-Dienst — der würde bei jedem
* Seitenaufruf verraten, wer hier gerade unterwegs ist.
*/
import { getFileURL } from '$lib/stores/pocketbase.svelte'
let {
user = null,
size = 32,
class: className = '',
}: {
user?: { id?: string; name?: string; email?: string; avatar?: string } | null
size?: number
class?: string
} = $props()
const label = $derived(user?.name?.trim() || user?.email?.trim() || '')
/** Erste Buchstaben der ersten beiden Wörter, sonst der erste des Namens. */
const initials = $derived.by(() => {
if (!label) return '?'
const words = label.split(/[\s.@_-]+/).filter(Boolean)
if (words.length === 0) return '?'
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[1][0]).toUpperCase()
})
/**
* Farbton aus der ID, hilfsweise aus dem Namen. Sättigung und Helligkeit
* bleiben fest, damit weiße Schrift auf jedem Ton lesbar bleibt — auch im
* Dunkelmodus, wo eine zufällige Helligkeit schnell blendet.
*/
const hue = $derived.by(() => {
const seed = user?.id || label || '?'
let h = 0
for (let i = 0; i < seed.length; i++) {
h = (h * 31 + seed.charCodeAt(i)) % 360
}
return h
})
const src = $derived(
user?.avatar
? getFileURL(user as object, user.avatar, { thumb: `${size * 2}x${size * 2}` })
: null,
)
</script>
{#if src}
<img
{src}
alt={label}
width={size}
height={size}
class="shrink-0 rounded-full object-cover {className}"
style:width="{size}px"
style:height="{size}px"
/>
{:else}
<span
aria-hidden="true"
class="grid shrink-0 place-items-center rounded-full font-medium text-white select-none {className}"
style:width="{size}px"
style:height="{size}px"
style:background="hsl({hue} 55% 42%)"
style:font-size="{Math.round(size * 0.4)}px"
>
{initials}
</span>
{/if}

View file

@ -15,6 +15,7 @@
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import Logo from '$lib/components/Logo.svelte' import Logo from '$lib/components/Logo.svelte'
import TeamSwitcher from '$lib/components/TeamSwitcher.svelte' import TeamSwitcher from '$lib/components/TeamSwitcher.svelte'
import Avatar from '$lib/components/Avatar.svelte'
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte' import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Bike } from 'lucide-svelte' import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Bike } from 'lucide-svelte'
import { toggleMode, mode } from 'mode-watcher' import { toggleMode, mode } from 'mode-watcher'
@ -123,7 +124,10 @@
Zeile, das Team-Dropdown darunter. --> Zeile, das Team-Dropdown darunter. -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="hidden md:flex flex-col items-end gap-1"> <div class="hidden md:flex flex-col items-end gap-1">
<div class="flex items-center gap-1"> <div class="flex items-center gap-2">
<a href="/dashboard/settings" title="Profil">
<Avatar user={auth.user} size={24} />
</a>
<p class="text-sm font-medium leading-none"> <p class="text-sm font-medium leading-none">
{auth.user.name || auth.user.email} {auth.user.name || auth.user.email}
</p> </p>
@ -187,9 +191,12 @@
<Separator class="my-2" /> <Separator class="my-2" />
<div class="flex items-center justify-between gap-2 px-3 py-2"> <div class="flex items-center justify-between gap-2 px-3 py-2">
<div class="flex flex-col items-start gap-1 min-w-0"> <div class="flex flex-col items-start gap-1 min-w-0">
<p class="text-sm font-medium leading-none truncate"> <span class="flex items-center gap-2 min-w-0">
{auth.user.name || auth.user.email} <Avatar user={auth.user} size={24} />
</p> <span class="text-sm font-medium leading-none truncate">
{auth.user.name || auth.user.email}
</span>
</span>
<TeamSwitcher /> <TeamSwitcher />
</div> </div>
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">

View file

@ -4,11 +4,12 @@
* Datensatz (updateRule `id = @request.auth.id`) — was hier steht, kann * Datensatz (updateRule `id = @request.auth.id`) — was hier steht, kann
* also niemand für jemand anderen ändern. * also niemand für jemand anderen ändern.
*/ */
import { api, auth, getFileURL } from '$lib/stores/pocketbase.svelte' import { api, auth } from '$lib/stores/pocketbase.svelte'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import Avatar from '$lib/components/Avatar.svelte'
import { KeyRound, User } from 'lucide-svelte' import { KeyRound, User } from 'lucide-svelte'
let name = $state(auth.user?.name ?? '') let name = $state(auth.user?.name ?? '')
@ -17,10 +18,6 @@
let profileMessage = $state<string | null>(null) let profileMessage = $state<string | null>(null)
let profileError = $state<string | null>(null) let profileError = $state<string | null>(null)
const avatarUrl = $derived(
auth.user?.avatar ? getFileURL(auth.user as object, auth.user.avatar as string) : null,
)
async function saveProfile() { async function saveProfile() {
if (!auth.user) return if (!auth.user) return
@ -108,13 +105,7 @@
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
{#if avatarUrl} <Avatar user={auth.user} size={64} />
<img src={avatarUrl} alt="" class="size-16 rounded-full object-cover border" />
{:else}
<div class="size-16 rounded-full border grid place-items-center text-muted-foreground">
<User class="size-6" />
</div>
{/if}
<div class="space-y-1"> <div class="space-y-1">
<Label for="avatar">Bild</Label> <Label for="avatar">Bild</Label>
<input <input
@ -124,6 +115,9 @@
class="block w-full text-sm" class="block w-full text-sm"
onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)} onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)}
/> />
<p class="text-xs text-muted-foreground">
Ohne Bild stehen deine Initialen da.
</p>
</div> </div>
</div> </div>

View file

@ -11,6 +11,7 @@
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog' 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 { Crown, LogOut, Plus, Shield, Trash2, UserMinus, UserPlus, Users } from 'lucide-svelte'
import type { TeamsResponse } from '$lib/types' import type { TeamsResponse } from '$lib/types'
@ -204,6 +205,7 @@
{@const isAdm = (t.admins ?? []).includes(userId)} {@const isAdm = (t.admins ?? []).includes(userId)}
<li class="py-3 flex items-center justify-between gap-2"> <li class="py-3 flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<Avatar user={teams.users[userId]} size={28} />
<span class="font-medium truncate">{teams.userLabel(userId)}</span> <span class="font-medium truncate">{teams.userLabel(userId)}</span>
{#if isOwner} {#if isOwner}
<Badge class="gap-1"> <Badge class="gap-1">