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"
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}
<line
x1={(hover.left / 100) * W}
y1="0"
x2={(hover.left / 100) * W}
y2={H}
stroke="rgb(14 165 233)"
stroke="rgb(255 255 255 / 0.9)"
stroke-width="1"
vector-effect="non-scaling-stroke"
/>
@ -188,7 +195,7 @@
zur Ellipse und Text mitverzerrt.
-->
<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:top="{hover.top}%"
></span>

View file

@ -1,16 +1,18 @@
<script lang="ts">
/**
* Icon-Auswahl: Vorschau, Freitextfeld und ein Raster mit Vorschlägen.
* Erlaubt ist jeder Name von lucide.dev — die Vorschläge sind nur die
* Abkürzung fürs häufig Gebrauchte.
* Icon-Auswahl: das Raster ist die Auswahl, nicht ein Vorschlag daneben.
*
* 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
* beide dieselbe Auswahl zeigen.
*/
import FlagIcon from './FlagIcon.svelte'
import { tooltip } from '$lib/stores/app.svelte'
import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons'
import { Input } from '@/components/ui/input'
import { FLAG_ICON_SUGGESTIONS } from '$lib/flagIcons'
import { Label } from '@/components/ui/label'
let {
@ -20,7 +22,7 @@
label = 'Icon',
}: {
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
id?: string
label?: string
@ -28,24 +30,11 @@
</script>
<div class="space-y-2">
<Label for={id}>{label}</Label>
<Label id="{id}-label">{label}</Label>
<div class="flex items-center gap-2">
<span class="grid size-9 shrink-0 place-items-center rounded-md border">
<FlagIcon name={value} {color} class="size-5" />
</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">
<!-- Eine Gruppe von Knoepfen, kein Eingabefeld: benannt ueber
aria-labelledby statt ueber ein <label for>. -->
<div role="group" aria-labelledby="{id}-label" class="grid grid-cols-10 gap-1 rounded-lg border p-2">
{#each FLAG_ICON_SUGGESTIONS as name (name)}
<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
}
/** 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
* nicht abschließend: Wer etwas anderes braucht, tippt den Namen ein.
* Die Auswahl, die im Dialog zur Wahl steht nach Themen sortiert.
*
* 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 = [
'TreePine', 'Blocks', 'Waves', 'Ban', 'Construction', 'Info',

View file

@ -14,63 +14,84 @@
import { Label } from '@/components/ui/label'
import Avatar from '$lib/components/Avatar.svelte'
import { Badge } from '@/components/ui/badge'
import * as Dialog from '@/components/ui/dialog'
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 avatarFile = $state<File | null>(null)
/**
* Vorschau des gewaehlten Bildes. Ohne sie bliebe die Wahl unsichtbar —
* das Dateifeld ist versteckt, es gibt also keinen Dateinamen zu lesen.
* Die Object-URL wird beim naechsten Wechsel wieder freigegeben, sonst
* haelt der Browser jedes einmal angesehene Bild bis zum Seitenwechsel
* fest.
* Vorschau des gerade hochgeladenen Bildes. Sie ueberbrueckt die Zeit, bis
* der Server das Bild hat und auth.refresh() es zurueckliefert — ohne sie
* bliebe das alte Bild stehen und der Klick sähe wirkungslos aus. Die
* Object-URL wird danach wieder freigegeben, sonst haelt der Browser jedes
* einmal angesehene Bild bis zum Seitenwechsel fest.
*/
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 profileMessage = $state<string | null>(null)
let profileError = $state<string | null>(null)
let messageTimer: ReturnType<typeof setTimeout> | null = null
async function saveProfile() {
if (!auth.user) return
function reportSaved() {
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
profileMessage = null
profileError = null
try {
// Mit Datei als FormData, sonst als einfaches Objekt — ein leeres
// avatar-Feld im FormData würde ein vorhandenes Bild löschen.
let data: FormData | Record<string, unknown>
if (avatarFile) {
const fd = new FormData()
fd.append('name', name.trim())
fd.append('avatar', avatarFile)
data = fd
} else {
data = { name: name.trim() }
await api.collection('users').update(auth.user.id, { name: next })
await auth.refresh()
reportSaved()
} catch (e: any) {
profileError = e.message ?? 'Der Name konnte nicht gespeichert werden.'
name = auth.user.name ?? ''
} finally {
savingProfile = false
}
}
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 auth.refresh()
avatarFile = null
profileMessage = 'Profil gespeichert.'
reportSaved()
} catch (e: any) {
profileError = e.message ?? 'Das Profil konnte nicht gespeichert werden.'
profileError = e.message ?? 'Das Bild konnte nicht gespeichert werden.'
} finally {
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 newPassword = $state('')
let confirmPassword = $state('')
let savingPassword = $state(false)
let passwordMessage = $state<string | null>(null)
let passwordError = $state<string | null>(null)
async function savePassword() {
@ -144,7 +168,6 @@
}
savingPassword = true
passwordMessage = null
passwordError = null
try {
await api.collection('users').update(auth.user.id, {
@ -161,7 +184,7 @@
}
oldPassword = newPassword = confirmPassword = ''
passwordMessage = 'Passwort geändert.'
passwordDialog = false
} catch (e: any) {
passwordError = e.message ?? 'Das Passwort konnte nicht geändert werden.'
} finally {
@ -170,7 +193,9 @@
}
</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>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
@ -186,9 +211,10 @@
<!--
Das Bild steht neben Name und Adresse und reicht ueber beide
Zeilen. Es ist zugleich der Knopf: Das Dateifeld liegt
unsichtbar darunter, angeklickt wird das Bild selbst — das
Label „Bild" ueber einem Dateifeld sagte nichts, was das Bild
nicht schon zeigt.
unsichtbar darunter, angeklickt wird das Bild selbst.
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">
<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"
>
{#if avatarPreview}
<img
src={avatarPreview}
alt=""
class="size-20 rounded-full object-cover"
/>
<img src={avatarPreview} alt="" class="size-20 rounded-full object-cover" />
{:else}
<Avatar user={auth.user} size={80} />
{/if}
@ -216,66 +238,38 @@
type="file"
accept="image/*"
class="sr-only"
onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)}
onchange={(e) => saveAvatar(e.currentTarget.files?.[0] ?? null)}
/>
</label>
<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 />
</div>
</div>
<p class="text-xs text-muted-foreground">
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
Team-Administration über PocketBase.
</p>
<div class="flex min-h-5 items-center justify-between gap-3">
{#if profileError}
<p class="text-sm text-destructive">{profileError}</p>
{:else if savingProfile}
<p class="text-sm text-muted-foreground">Wird gespeichert …</p>
{:else if profileMessage}
<p class="text-sm text-muted-foreground">{profileMessage}</p>
{:else}
<span></span>
{/if}
<Button onclick={saveProfile} disabled={savingProfile}>
{savingProfile ? 'Wird gespeichert …' : 'Speichern'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<KeyRound class="size-4 text-primary" />
<Button variant="outline" size="sm" onclick={() => (passwordDialog = true)}>
<KeyRound class="size-4 mr-2" />
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>
</div>
</CardContent>
</Card>
@ -283,7 +277,7 @@
Eine eigene Karte, ganz unten: Was nicht rückgängig zu machen ist,
steht nicht zwischen Namensfeld und Passwortwechsel.
-->
<Card class="lg:col-span-2 border-destructive/30">
<Card class="border-destructive/30">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Trash2 class="size-4 text-destructive" />
@ -318,3 +312,43 @@
</CardContent>
</Card>
</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>