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:
parent
ed3d261a2d
commit
6ee7801d78
4 changed files with 160 additions and 131 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
{#if profileError}
|
||||||
Team-Administration über PocketBase.
|
<p class="text-sm text-destructive">{profileError}</p>
|
||||||
</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}
|
||||||
|
|
||||||
{#if profileError}
|
<Button variant="outline" size="sm" onclick={() => (passwordDialog = true)}>
|
||||||
<p class="text-sm text-destructive">{profileError}</p>
|
<KeyRound class="size-4 mr-2" />
|
||||||
{:else if profileMessage}
|
Passwort ändern
|
||||||
<p class="text-sm text-muted-foreground">{profileMessage}</p>
|
</Button>
|
||||||
{/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" />
|
|
||||||
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>
|
||||||
<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>
|
|
||||||
</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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue