refactor: Icon-Auswahl ohne Freitext, Hoehenprofil weiss, Profil speichert selbst

Icon-Auswahl: Das Raster ist jetzt die Auswahl. Daneben stand ein
Freitextfeld fuer jeden Namen von lucide.dev samt Anleitung. Das
verlangte, einen Katalog von tausend Icons zu kennen und den Namen
fehlerfrei abzutippen, und belohnte den Tippfehler mit einem leeren
Kaestchen. Fehlt ein Icon, gehoert es in FLAG_ICON_SUGGESTIONS.

Hoehenprofil in Weiss zu 90 % statt in Blau.

Profil: kein Speichern-Knopf mehr. Es sind zwei Felder, beide gehoeren
nur dem Kontoinhaber — der Name wird uebernommen, sobald das Feld den
Fokus verliert oder Enter kommt, das Bild, sobald eines gewaehlt ist.
Der Satz zur E-Mail-Adresse faellt weg: Das Feld ist abgeschaltet, das
sagt schon alles, was er erklaeren wollte.

Der Passwortwechsel steckt hinter einem Knopf in einem Dialog. Er ist
die Ausnahme; drei Felder dafuer standen dauerhaft offen auf der Seite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
This commit is contained in:
Daniel Michelberger 2026-09-07 22:46:49 +02:00
parent ed3d261a2d
commit 6ee7801d78
4 changed files with 160 additions and 131 deletions

View file

@ -166,14 +166,21 @@
role="img" role="img"
aria-hidden="true" aria-hidden="true"
> >
<path d={path} fill="rgb(14 165 233 / 0.2)" stroke="rgb(14 165 233)" stroke-width="2" vector-effect="non-scaling-stroke" /> <!-- Weiss zu 90 %, die Flaeche darunter blass. -->
<path
d={path}
fill="rgb(255 255 255 / 0.2)"
stroke="rgb(255 255 255 / 0.9)"
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
{#if hover} {#if hover}
<line <line
x1={(hover.left / 100) * W} x1={(hover.left / 100) * W}
y1="0" y1="0"
x2={(hover.left / 100) * W} x2={(hover.left / 100) * W}
y2={H} y2={H}
stroke="rgb(14 165 233)" stroke="rgb(255 255 255 / 0.9)"
stroke-width="1" stroke-width="1"
vector-effect="non-scaling-stroke" vector-effect="non-scaling-stroke"
/> />
@ -188,7 +195,7 @@
zur Ellipse und Text mitverzerrt. zur Ellipse und Text mitverzerrt.
--> -->
<span <span
class="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-sky-500 ring-2 ring-background" class="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white ring-2 ring-background"
style:left="{hover.left}%" style:left="{hover.left}%"
style:top="{hover.top}%" style:top="{hover.top}%"
></span> ></span>

View file

@ -1,16 +1,18 @@
<script lang="ts"> <script lang="ts">
/** /**
* Icon-Auswahl: Vorschau, Freitextfeld und ein Raster mit Vorschlägen. * Icon-Auswahl: das Raster ist die Auswahl, nicht ein Vorschlag daneben.
* Erlaubt ist jeder Name von lucide.dev — die Vorschläge sind nur die *
* Abkürzung fürs häufig Gebrauchte. * Frueher stand hier zusaetzlich ein Freitextfeld fuer jeden beliebigen
* Namen von lucide.dev. Das verlangte, einen Katalog von tausend Icons zu
* kennen und den Namen fehlerfrei abzutippen, und belohnte den Tippfehler
* mit einem leeren Kaestchen. Was zur Wahl steht, steht jetzt da.
* *
* Gemeinsam genutzt von der Flag-Verwaltung und dem Marker-Dialog, damit * Gemeinsam genutzt von der Flag-Verwaltung und dem Marker-Dialog, damit
* beide dieselbe Auswahl zeigen. * beide dieselbe Auswahl zeigen.
*/ */
import FlagIcon from './FlagIcon.svelte' import FlagIcon from './FlagIcon.svelte'
import { tooltip } from '$lib/stores/app.svelte' import { tooltip } from '$lib/stores/app.svelte'
import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons' import { FLAG_ICON_SUGGESTIONS } from '$lib/flagIcons'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
let { let {
@ -20,7 +22,7 @@
label = 'Icon', label = 'Icon',
}: { }: {
value?: string value?: string
/** Färbt Vorschau und Vorschläge, damit man das Ergebnis sieht. */ /** Faerbt die Icons im Raster, damit man das Ergebnis sieht. */
color?: string color?: string
id?: string id?: string
label?: string label?: string
@ -28,24 +30,11 @@
</script> </script>
<div class="space-y-2"> <div class="space-y-2">
<Label for={id}>{label}</Label> <Label id="{id}-label">{label}</Label>
<div class="flex items-center gap-2"> <!-- Eine Gruppe von Knoepfen, kein Eingabefeld: benannt ueber
<span class="grid size-9 shrink-0 place-items-center rounded-md border"> aria-labelledby statt ueber ein <label for>. -->
<FlagIcon name={value} {color} class="size-5" /> <div role="group" aria-labelledby="{id}-label" class="grid grid-cols-10 gap-1 rounded-lg border p-2">
</span>
<Input {id} bind:value placeholder="z. B. TreePine" class="flex-1" />
</div>
<p class="text-xs text-muted-foreground">
Jeder Name von <a href="https://lucide.dev/icons" target="_blank" rel="noreferrer" class="underline">lucide.dev</a>
ist möglich — geschrieben wie „TreePine" oder „tree-pine".
{#if value.trim() && !iconExists(value)}
<span class="text-destructive">Diesen Namen gibt es dort nicht.</span>
{/if}
</p>
<div class="grid grid-cols-10 gap-1 rounded-lg border p-2">
{#each FLAG_ICON_SUGGESTIONS as name (name)} {#each FLAG_ICON_SUGGESTIONS as name (name)}
<button <button
type="button" type="button"

View file

@ -31,14 +31,13 @@ export function iconLoader(name: string | undefined): (() => Promise<IconModule>
return modules[`/node_modules/lucide-svelte/dist/icons/${toKebab(name)}.svelte`] ?? null return modules[`/node_modules/lucide-svelte/dist/icons/${toKebab(name)}.svelte`] ?? null
} }
/** Ob ein eingegebener Name existiert — für die Rückmeldung im Dialog. */
export function iconExists(name: string | undefined): boolean {
return iconLoader(name) !== null
}
/** /**
* Vorschläge für die Auswahl im Dialog. Bewusst nach Themen sortiert und * Die Auswahl, die im Dialog zur Wahl steht nach Themen sortiert.
* nicht abschließend: Wer etwas anderes braucht, tippt den Namen ein. *
* Abschliessend: Es gab einmal ein Freitextfeld daneben, in das sich jeder
* Name von lucide.dev eintippen liess. Das verlangte, einen Katalog von
* tausend Icons zu kennen, und belohnte den Tippfehler mit einem leeren
* Kaestchen. Fehlt hier etwas, gehoert es in diese Liste.
*/ */
export const FLAG_ICON_SUGGESTIONS = [ export const FLAG_ICON_SUGGESTIONS = [
'TreePine', 'Blocks', 'Waves', 'Ban', 'Construction', 'Info', 'TreePine', 'Blocks', 'Waves', 'Ban', 'Construction', 'Info',

View file

@ -14,63 +14,84 @@
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import Avatar from '$lib/components/Avatar.svelte' import Avatar from '$lib/components/Avatar.svelte'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import * as Dialog from '@/components/ui/dialog'
import { Camera, KeyRound, Trash2, User } from 'lucide-svelte' import { Camera, KeyRound, Trash2, User } from 'lucide-svelte'
// --- Name und Bild: sofort speichern ---------------------------------
//
// Kein Speichern-Knopf. Es sind zwei Felder, beide gehoeren nur dem
// Kontoinhaber, und ein Knopf, der fast immer nichts zu tun hat, ist eine
// Frage an jemanden, der laengst geantwortet hat: Der Name wird
// uebernommen, sobald das Feld den Fokus verliert oder Enter kommt, das
// Bild, sobald eines gewaehlt ist.
let name = $state(auth.user?.name ?? '') let name = $state(auth.user?.name ?? '')
let avatarFile = $state<File | null>(null)
/** /**
* Vorschau des gewaehlten Bildes. Ohne sie bliebe die Wahl unsichtbar — * Vorschau des gerade hochgeladenen Bildes. Sie ueberbrueckt die Zeit, bis
* das Dateifeld ist versteckt, es gibt also keinen Dateinamen zu lesen. * der Server das Bild hat und auth.refresh() es zurueckliefert — ohne sie
* Die Object-URL wird beim naechsten Wechsel wieder freigegeben, sonst * bliebe das alte Bild stehen und der Klick sähe wirkungslos aus. Die
* haelt der Browser jedes einmal angesehene Bild bis zum Seitenwechsel * Object-URL wird danach wieder freigegeben, sonst haelt der Browser jedes
* fest. * einmal angesehene Bild bis zum Seitenwechsel fest.
*/ */
let avatarPreview = $state<string | null>(null) let avatarPreview = $state<string | null>(null)
$effect(() => {
const file = avatarFile
if (!file) {
avatarPreview = null
return
}
const url = URL.createObjectURL(file)
avatarPreview = url
return () => URL.revokeObjectURL(url)
})
let savingProfile = $state(false) let savingProfile = $state(false)
let profileMessage = $state<string | null>(null) let profileMessage = $state<string | null>(null)
let profileError = $state<string | null>(null) let profileError = $state<string | null>(null)
let messageTimer: ReturnType<typeof setTimeout> | null = null
async function saveProfile() { function reportSaved() {
if (!auth.user) return profileMessage = 'Gespeichert.'
if (messageTimer) clearTimeout(messageTimer)
messageTimer = setTimeout(() => (profileMessage = null), 2500)
}
async function saveName() {
const next = name.trim()
if (!auth.user || savingProfile) return
// Nichts geaendert, nichts zu speichern — sonst schriebe jeder
// Fokuswechsel denselben Namen erneut.
if (next === (auth.user.name ?? '')) return
savingProfile = true savingProfile = true
profileMessage = null
profileError = null profileError = null
try { try {
// Mit Datei als FormData, sonst als einfaches Objekt — ein leeres await api.collection('users').update(auth.user.id, { name: next })
// avatar-Feld im FormData würde ein vorhandenes Bild löschen. await auth.refresh()
let data: FormData | Record<string, unknown> reportSaved()
if (avatarFile) { } catch (e: any) {
const fd = new FormData() profileError = e.message ?? 'Der Name konnte nicht gespeichert werden.'
fd.append('name', name.trim()) name = auth.user.name ?? ''
fd.append('avatar', avatarFile) } finally {
data = fd savingProfile = false
} else {
data = { name: name.trim() }
} }
}
async function saveAvatar(file: File | null) {
if (!auth.user || !file) return
const url = URL.createObjectURL(file)
avatarPreview = url
savingProfile = true
profileError = null
try {
// Als FormData, weil eine Datei mitkommt. Ein leeres avatar-Feld
// im FormData wuerde ein vorhandenes Bild loeschen — deshalb wird
// der Name hier nicht mitgeschickt, der hat seinen eigenen Weg.
const data = new FormData()
data.append('avatar', file)
await api.collection('users').update(auth.user.id, data) await api.collection('users').update(auth.user.id, data)
await auth.refresh() await auth.refresh()
reportSaved()
avatarFile = null
profileMessage = 'Profil gespeichert.'
} catch (e: any) { } catch (e: any) {
profileError = e.message ?? 'Das Profil konnte nicht gespeichert werden.' profileError = e.message ?? 'Das Bild konnte nicht gespeichert werden.'
} finally { } finally {
savingProfile = false savingProfile = false
avatarPreview = null
URL.revokeObjectURL(url)
} }
} }
@ -124,11 +145,14 @@
}) })
} }
// --- Passwort ---------------------------------------------------------
// Hinter einem Knopf und in einem Dialog: Ein Passwortwechsel ist die
// Ausnahme, drei Felder dafuer standen dauerhaft offen auf der Seite.
let passwordDialog = $state(false)
let oldPassword = $state('') let oldPassword = $state('')
let newPassword = $state('') let newPassword = $state('')
let confirmPassword = $state('') let confirmPassword = $state('')
let savingPassword = $state(false) let savingPassword = $state(false)
let passwordMessage = $state<string | null>(null)
let passwordError = $state<string | null>(null) let passwordError = $state<string | null>(null)
async function savePassword() { async function savePassword() {
@ -144,7 +168,6 @@
} }
savingPassword = true savingPassword = true
passwordMessage = null
passwordError = null passwordError = null
try { try {
await api.collection('users').update(auth.user.id, { await api.collection('users').update(auth.user.id, {
@ -161,7 +184,7 @@
} }
oldPassword = newPassword = confirmPassword = '' oldPassword = newPassword = confirmPassword = ''
passwordMessage = 'Passwort geändert.' passwordDialog = false
} catch (e: any) { } catch (e: any) {
passwordError = e.message ?? 'Das Passwort konnte nicht geändert werden.' passwordError = e.message ?? 'Das Passwort konnte nicht geändert werden.'
} finally { } finally {
@ -170,7 +193,9 @@
} }
</script> </script>
<div class="grid gap-6 lg:grid-cols-2"> <!-- Eine Spalte: Es sind zwei Karten, und Profilfelder werden nicht breiter,
wenn man sie in die Breite zieht. -->
<div class="max-w-2xl space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="flex items-center gap-2 text-base"> <CardTitle class="flex items-center gap-2 text-base">
@ -186,9 +211,10 @@
<!-- <!--
Das Bild steht neben Name und Adresse und reicht ueber beide Das Bild steht neben Name und Adresse und reicht ueber beide
Zeilen. Es ist zugleich der Knopf: Das Dateifeld liegt Zeilen. Es ist zugleich der Knopf: Das Dateifeld liegt
unsichtbar darunter, angeklickt wird das Bild selbst — das unsichtbar darunter, angeklickt wird das Bild selbst.
Label „Bild" ueber einem Dateifeld sagte nichts, was das Bild
nicht schon zeigt. Zur E-Mail steht nichts weiter: Das Feld ist abgeschaltet, das
sagt schon alles, was ein Satz darunter erklaeren wollte.
--> -->
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<label <label
@ -196,11 +222,7 @@
class="group relative shrink-0 cursor-pointer rounded-full ring-offset-2 ring-offset-card focus-within:ring-2 focus-within:ring-ring" class="group relative shrink-0 cursor-pointer rounded-full ring-offset-2 ring-offset-card focus-within:ring-2 focus-within:ring-ring"
> >
{#if avatarPreview} {#if avatarPreview}
<img <img src={avatarPreview} alt="" class="size-20 rounded-full object-cover" />
src={avatarPreview}
alt=""
class="size-20 rounded-full object-cover"
/>
{:else} {:else}
<Avatar user={auth.user} size={80} /> <Avatar user={auth.user} size={80} />
{/if} {/if}
@ -216,66 +238,38 @@
type="file" type="file"
accept="image/*" accept="image/*"
class="sr-only" class="sr-only"
onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)} onchange={(e) => saveAvatar(e.currentTarget.files?.[0] ?? null)}
/> />
</label> </label>
<div class="min-w-0 flex-1 space-y-2"> <div class="min-w-0 flex-1 space-y-2">
<Input aria-label="Name" bind:value={name} placeholder="Wie du angezeigt wirst" /> <Input
aria-label="Name"
bind:value={name}
placeholder="Wie du angezeigt wirst"
onblur={saveName}
onkeydown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
/>
<Input aria-label="E-Mail" value={auth.user?.email ?? ''} readonly disabled /> <Input aria-label="E-Mail" value={auth.user?.email ?? ''} readonly disabled />
</div> </div>
</div> </div>
<p class="text-xs text-muted-foreground"> <div class="flex min-h-5 items-center justify-between gap-3">
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
Team-Administration über PocketBase.
</p>
{#if profileError} {#if profileError}
<p class="text-sm text-destructive">{profileError}</p> <p class="text-sm text-destructive">{profileError}</p>
{:else if savingProfile}
<p class="text-sm text-muted-foreground">Wird gespeichert …</p>
{:else if profileMessage} {:else if profileMessage}
<p class="text-sm text-muted-foreground">{profileMessage}</p> <p class="text-sm text-muted-foreground">{profileMessage}</p>
{:else}
<span></span>
{/if} {/if}
<Button onclick={saveProfile} disabled={savingProfile}> <Button variant="outline" size="sm" onclick={() => (passwordDialog = true)}>
{savingProfile ? 'Wird gespeichert …' : 'Speichern'} <KeyRound class="size-4 mr-2" />
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<KeyRound class="size-4 text-primary" />
Passwort ändern Passwort ändern
</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="pw-old">Aktuelles Passwort</Label>
<Input id="pw-old" type="password" bind:value={oldPassword} autocomplete="current-password" />
</div>
<div class="space-y-2">
<Label for="pw-new">Neues Passwort</Label>
<Input id="pw-new" type="password" bind:value={newPassword} autocomplete="new-password" />
</div>
<div class="space-y-2">
<Label for="pw-confirm">Wiederholen</Label>
<Input id="pw-confirm" type="password" bind:value={confirmPassword} autocomplete="new-password" />
</div>
{#if passwordError}
<p class="text-sm text-destructive">{passwordError}</p>
{:else if passwordMessage}
<p class="text-sm text-muted-foreground">{passwordMessage}</p>
{/if}
<Button
onclick={savePassword}
disabled={savingPassword || !oldPassword || !newPassword}
>
{savingPassword ? 'Wird geändert …' : 'Passwort ändern'}
</Button> </Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
@ -283,7 +277,7 @@
Eine eigene Karte, ganz unten: Was nicht rückgängig zu machen ist, Eine eigene Karte, ganz unten: Was nicht rückgängig zu machen ist,
steht nicht zwischen Namensfeld und Passwortwechsel. steht nicht zwischen Namensfeld und Passwortwechsel.
--> -->
<Card class="lg:col-span-2 border-destructive/30"> <Card class="border-destructive/30">
<CardHeader> <CardHeader>
<CardTitle class="flex items-center gap-2 text-base"> <CardTitle class="flex items-center gap-2 text-base">
<Trash2 class="size-4 text-destructive" /> <Trash2 class="size-4 text-destructive" />
@ -318,3 +312,43 @@
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<!-- Passwort ändern -->
<Dialog.Root bind:open={passwordDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Passwort ändern</Dialog.Title>
<Dialog.Description>
Nach der Änderung wirst du still neu angemeldet — PocketBase entwertet
mit dem Wechsel alle Tokens, auch den dieser Sitzung.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="pw-old">Aktuelles Passwort</Label>
<Input id="pw-old" type="password" bind:value={oldPassword} autocomplete="current-password" />
</div>
<div class="space-y-2">
<Label for="pw-new">Neues Passwort</Label>
<Input id="pw-new" type="password" bind:value={newPassword} autocomplete="new-password" />
<p class="text-xs text-muted-foreground">Mindestens 8 Zeichen.</p>
</div>
<div class="space-y-2">
<Label for="pw-confirm">Wiederholen</Label>
<Input id="pw-confirm" type="password" bind:value={confirmPassword} autocomplete="new-password" />
</div>
{#if passwordError}
<p class="text-sm text-destructive">{passwordError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (passwordDialog = false)}>Abbrechen</Button>
<Button onclick={savePassword} disabled={savingPassword || !oldPassword || !newPassword}>
{savingPassword ? 'Wird geändert …' : 'Passwort ändern'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>