feat(persons): add /persons/[id]/edit route with PersonEditForm, PersonDangerZone
New edit route with WRITE_ALL guard; PersonEditForm (6 fields), sticky PersonEditSaveBar, collapsed PersonDangerZone with PersonMergePanel. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
87
frontend/src/routes/persons/[id]/edit/+page.server.ts
Normal file
87
frontend/src/routes/persons/[id]/edit/+page.server.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
export async function load({ params, fetch, locals }) {
|
||||
const canWrite =
|
||||
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false;
|
||||
|
||||
if (!canWrite) throw error(403, 'Forbidden');
|
||||
|
||||
const { id } = params;
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
return { person: result.data! };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
update: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const firstName = formData.get('firstName')?.toString().trim();
|
||||
const lastName = formData.get('lastName')?.toString().trim();
|
||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||
const birthYearStr = formData.get('birthYear')?.toString().trim();
|
||||
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
||||
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
|
||||
}
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.PUT('/api/persons/{id}', {
|
||||
params: { path: { id: params.id } },
|
||||
body: {
|
||||
firstName,
|
||||
lastName,
|
||||
...(alias ? { alias } : {}),
|
||||
...(notes ? { notes } : {}),
|
||||
...(birthYear ? { birthYear } : {}),
|
||||
...(deathYear ? { deathYear } : {})
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { updateError: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
throw redirect(303, `/persons/${params.id}`);
|
||||
},
|
||||
|
||||
discard: async ({ params }) => {
|
||||
throw redirect(303, `/persons/${params.id}`);
|
||||
},
|
||||
|
||||
merge: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const targetPersonId = formData.get('targetPersonId')?.toString();
|
||||
|
||||
if (!targetPersonId) {
|
||||
return fail(400, { mergeError: 'Bitte eine Zielperson auswählen.' });
|
||||
}
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.POST('/api/persons/{id}/merge', {
|
||||
params: { path: { id: params.id } },
|
||||
body: { targetPersonId }
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { mergeError: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
throw redirect(303, `/persons/${targetPersonId}`);
|
||||
}
|
||||
};
|
||||
56
frontend/src/routes/persons/[id]/edit/+page.svelte
Normal file
56
frontend/src/routes/persons/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { enhance } from '$app/forms';
|
||||
import PersonEditForm from './PersonEditForm.svelte';
|
||||
import PersonEditSaveBar from './PersonEditSaveBar.svelte';
|
||||
import PersonDangerZone from './PersonDangerZone.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
const person = $derived(data.person);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||
<!-- Back link -->
|
||||
<div class="mb-6">
|
||||
<a
|
||||
href="/persons/{person.id}"
|
||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
</a>
|
||||
<h1 class="font-serif text-3xl text-ink">{m.person_edit_heading()}</h1>
|
||||
</div>
|
||||
|
||||
{#if form?.updateError}
|
||||
<div class="mb-6 rounded border border-red-200 bg-red-50 p-4 text-red-700">
|
||||
{form.updateError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" use:enhance>
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.persons_section_details()}
|
||||
</h2>
|
||||
<PersonEditForm person={person} />
|
||||
</div>
|
||||
|
||||
<PersonDangerZone person={person} form={form} />
|
||||
|
||||
<PersonEditSaveBar discardHref="/persons/{person.id}" />
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import PersonMergePanel from '../PersonMergePanel.svelte';
|
||||
|
||||
let {
|
||||
person,
|
||||
form
|
||||
}: {
|
||||
person: { id: string; firstName: string; lastName: string };
|
||||
form?: { mergeError?: string } | null;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="mt-8 overflow-hidden rounded-sm border border-red-200 bg-surface shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (open = !open)}
|
||||
class="flex w-full items-center justify-between px-6 py-4 text-left"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span class="text-sm font-bold tracking-widest text-red-600 uppercase">
|
||||
{m.person_danger_zone_heading()}
|
||||
</span>
|
||||
<svg
|
||||
class="h-4 w-4 text-red-400 transition-transform {open ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="border-t border-red-100 px-6 py-4">
|
||||
{#key person.id}
|
||||
<PersonMergePanel person={person} form={form} />
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
100
frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte
Normal file
100
frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
person
|
||||
}: {
|
||||
person: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
alias?: string | null;
|
||||
birthYear?: number | null;
|
||||
deathYear?: number | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="firstName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.form_label_first_name()} *</label
|
||||
>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
value={person.firstName}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="lastName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.form_label_last_name()} *</label
|
||||
>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
value={person.lastName}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="alias" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.form_label_alias()}</label
|
||||
>
|
||||
<input
|
||||
id="alias"
|
||||
name="alias"
|
||||
type="text"
|
||||
value={person.alias ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="birthYear" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.person_label_birth_year()}</label
|
||||
>
|
||||
<input
|
||||
id="birthYear"
|
||||
name="birthYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
value={person.birthYear ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="deathYear" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.person_label_death_year()}</label
|
||||
>
|
||||
<input
|
||||
id="deathYear"
|
||||
name="deathYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
value={person.deathYear ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="notes" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.person_label_notes()}</label
|
||||
>
|
||||
<textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows="4"
|
||||
placeholder={m.person_placeholder_notes()}
|
||||
class="block w-full resize-y rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||
>{person.notes ?? ''}</textarea
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { discardHref }: { discardHref: string } = $props();
|
||||
</script>
|
||||
|
||||
<!-- Sticky full-bleed save bar -->
|
||||
<div
|
||||
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
|
||||
>
|
||||
<a href={discardHref} class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
|
||||
{m.person_discard_changes()}
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
formaction="?/update"
|
||||
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/80"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
</div>
|
||||
Reference in New Issue
Block a user