diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 04be49cb..cd4ad3de 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -155,6 +155,11 @@ "admin_multiselect_hint_full": "Strg+Klick für Mehrfachauswahl", "admin_section_tags": "Schlagworte", "admin_tags_warning": "Warnung: Umbenennen oder Löschen wirkt sich auf alle verknüpften Dokumente aus.", + "admin_tags_list_title": "Alle Schlagworte", + "admin_tags_empty": "Keine Schlagworte vorhanden.", + "admin_tags_select_prompt": "W\u00e4hle ein Schlagwort aus der Liste.", + "admin_tag_edit_heading": "Schlagwort: {name}", + "admin_tag_updated": "Schlagwort umbenannt.", "admin_btn_edit_tag_label": "Schlagwort bearbeiten", "admin_tag_delete_confirm": "Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.", "admin_btn_delete_tag_label": "Schlagwort löschen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 9346ec74..1484b937 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -155,6 +155,11 @@ "admin_multiselect_hint_full": "Ctrl+Click for multiple selection", "admin_section_tags": "Tags", "admin_tags_warning": "Warning: Renaming or deleting affects all linked documents.", + "admin_tags_list_title": "All Tags", + "admin_tags_empty": "No tags found.", + "admin_tags_select_prompt": "Select a tag from the list.", + "admin_tag_edit_heading": "Tag: {name}", + "admin_tag_updated": "Tag renamed.", "admin_btn_edit_tag_label": "Edit tag", "admin_tag_delete_confirm": "Really delete? The tag will be removed from all documents.", "admin_btn_delete_tag_label": "Delete tag", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index d5850122..381ba174 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -155,6 +155,11 @@ "admin_multiselect_hint_full": "Ctrl+Clic para selección múltiple", "admin_section_tags": "Etiquetas", "admin_tags_warning": "Advertencia: Renombrar o eliminar afecta a todos los documentos vinculados.", + "admin_tags_list_title": "Todas las etiquetas", + "admin_tags_empty": "No hay etiquetas.", + "admin_tags_select_prompt": "Selecciona una etiqueta de la lista.", + "admin_tag_edit_heading": "Etiqueta: {name}", + "admin_tag_updated": "Etiqueta renombrada.", "admin_btn_edit_tag_label": "Editar etiqueta", "admin_tag_delete_confirm": "¿Realmente eliminar? La etiqueta se eliminará de todos los documentos.", "admin_btn_delete_tag_label": "Eliminar etiqueta", diff --git a/frontend/src/routes/admin/tags/+layout.server.ts b/frontend/src/routes/admin/tags/+layout.server.ts new file mode 100644 index 00000000..01255d35 --- /dev/null +++ b/frontend/src/routes/admin/tags/+layout.server.ts @@ -0,0 +1,8 @@ +import { createApiClient } from '$lib/api.server'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ fetch }) => { + const api = createApiClient(fetch); + const result = await api.GET('/api/tags'); + return { tags: result.data ?? [] }; +}; diff --git a/frontend/src/routes/admin/tags/+layout.svelte b/frontend/src/routes/admin/tags/+layout.svelte new file mode 100644 index 00000000..405779b2 --- /dev/null +++ b/frontend/src/routes/admin/tags/+layout.svelte @@ -0,0 +1,12 @@ + + + + + +
+ {@render children()} +
diff --git a/frontend/src/routes/admin/tags/+page.svelte b/frontend/src/routes/admin/tags/+page.svelte new file mode 100644 index 00000000..5db9b65c --- /dev/null +++ b/frontend/src/routes/admin/tags/+page.svelte @@ -0,0 +1,7 @@ + + +
+

{m.admin_tags_select_prompt()}

+
diff --git a/frontend/src/routes/admin/tags/TagsListPanel.svelte b/frontend/src/routes/admin/tags/TagsListPanel.svelte new file mode 100644 index 00000000..4a910bb4 --- /dev/null +++ b/frontend/src/routes/admin/tags/TagsListPanel.svelte @@ -0,0 +1,42 @@ + + +
+ +
+ + {m.admin_tags_list_title()} + +
+ + +
+ {#if tags.length === 0} +

+ {m.admin_tags_empty()} +

+ {:else} + {#each tags as tag (tag.id)} + {@const isActive = page.url.pathname.startsWith('/admin/tags/' + tag.id)} + +
{tag.name}
+
+ {/each} + {/if} +
+
diff --git a/frontend/src/routes/admin/tags/[id]/+page.server.ts b/frontend/src/routes/admin/tags/[id]/+page.server.ts new file mode 100644 index 00000000..0dbace2c --- /dev/null +++ b/frontend/src/routes/admin/tags/[id]/+page.server.ts @@ -0,0 +1,44 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; + +export const load: PageServerLoad = async ({ params, parent }) => { + const { tags } = await parent(); + const tag = tags.find((t: { id: string }) => t.id === params.id); + if (!tag) throw error(404, getErrorMessage('TAG_NOT_FOUND')); + return { tag }; +}; + +export const actions: Actions = { + update: async ({ params, request, fetch }) => { + const data = await request.formData(); + const api = createApiClient(fetch); + + const result = await api.PUT('/api/tags/{id}', { + params: { path: { id: params.id } }, + body: { name: data.get('name') as string } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { error: getErrorMessage(code) }); + } + + return { success: true }; + }, + + delete: async ({ params, fetch }) => { + const api = createApiClient(fetch); + const result = await api.DELETE('/api/tags/{id}', { + params: { path: { id: params.id } } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { error: getErrorMessage(code) }); + } + + throw redirect(303, '/admin/tags'); + } +}; diff --git a/frontend/src/routes/admin/tags/[id]/+page.svelte b/frontend/src/routes/admin/tags/[id]/+page.svelte new file mode 100644 index 00000000..078b2601 --- /dev/null +++ b/frontend/src/routes/admin/tags/[id]/+page.svelte @@ -0,0 +1,96 @@ + + +
+ +
+

+ {m.admin_tag_edit_heading({ name: data.tag.name })} +

+
+ + +
+ {#if form?.success} +
+ {m.admin_tag_updated()} +
+ {/if} + {#if form?.error} +
+ {form.error} +
+ {/if} + + +
+
+

+ {m.admin_col_name()} +

+

{m.admin_tags_warning()}

+ +
+
+ + +
+

+ {m.btn_delete()} +

+

+ {m.admin_tag_delete_confirm()} +

+

+ Gib {data.tag.name} zur Bestätigung ein: +

+ +
+ +
+
+
+ + +
+ + {m.btn_cancel()} + + +
+
diff --git a/frontend/src/routes/admin/tags/layout.server.spec.ts b/frontend/src/routes/admin/tags/layout.server.spec.ts new file mode 100644 index 00000000..466a970b --- /dev/null +++ b/frontend/src/routes/admin/tags/layout.server.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { load } from './+layout.server'; + +vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() })); + +import { createApiClient } from '$lib/api.server'; + +function mockApi(tags: unknown[]) { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockResolvedValueOnce({ response: { ok: true }, data: tags }) + } as ReturnType); +} + +beforeEach(() => vi.clearAllMocks()); + +describe('admin/tags layout load', () => { + it('returns the tags list', async () => { + mockApi([ + { id: 't1', name: 'Familie' }, + { id: 't2', name: 'Urlaub' } + ]); + const result = await load({ fetch: vi.fn() as unknown as typeof fetch }); + expect(result.tags).toHaveLength(2); + expect(result.tags[0].name).toBe('Familie'); + }); + + it('returns an empty array when the API returns nothing', async () => { + mockApi([]); + const result = await load({ fetch: vi.fn() as unknown as typeof fetch }); + expect(result.tags).toEqual([]); + }); + + it('calls GET /api/tags', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true }, data: [] }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + await load({ fetch: vi.fn() as unknown as typeof fetch }); + expect(mockGet).toHaveBeenCalledWith('/api/tags'); + }); +}); diff --git a/frontend/src/routes/admin/tags/layout.svelte.spec.ts b/frontend/src/routes/admin/tags/layout.svelte.spec.ts new file mode 100644 index 00000000..2fbd3e5b --- /dev/null +++ b/frontend/src/routes/admin/tags/layout.svelte.spec.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import TagsListPanel from './TagsListPanel.svelte'; + +vi.mock('$app/state', () => ({ + page: { url: { pathname: '/admin/tags/t1' } } +})); + +afterEach(cleanup); + +const tags = [ + { id: 't1', name: 'Familie' }, + { id: 't2', name: 'Urlaub' }, + { id: 't3', name: 'Schule' } +]; + +describe('TagsListPanel — header', () => { + it('renders the panel title', async () => { + render(TagsListPanel, { tags }); + await expect.element(page.getByText(/Alle Schlagworte/i)).toBeInTheDocument(); + }); +}); + +describe('TagsListPanel — tag items', () => { + it('renders each tag name', async () => { + render(TagsListPanel, { tags }); + await expect.element(page.getByRole('link', { name: /familie/i })).toBeInTheDocument(); + await expect.element(page.getByRole('link', { name: /urlaub/i })).toBeInTheDocument(); + }); + + it('each tag links to /admin/tags/[id]', async () => { + const { container } = render(TagsListPanel, { tags }); + const links = container.querySelectorAll('a[href^="/admin/tags/t"]'); + expect(links.length).toBe(3); + expect(links[0].getAttribute('href')).toBe('/admin/tags/t1'); + }); +}); + +describe('TagsListPanel — active state', () => { + it('marks the active tag link with aria-current=page', async () => { + render(TagsListPanel, { tags }); + await expect + .element(page.getByRole('link', { name: /familie/i })) + .toHaveAttribute('aria-current', 'page'); + }); + + it('does not mark inactive tag links with aria-current', async () => { + render(TagsListPanel, { tags }); + await expect + .element(page.getByRole('link', { name: /urlaub/i })) + .not.toHaveAttribute('aria-current'); + }); +}); + +describe('TagsListPanel — empty state', () => { + it('shows empty state when tags array is empty', async () => { + render(TagsListPanel, { tags: [] }); + await expect.element(page.getByText(/keine schlagworte/i)).toBeInTheDocument(); + }); +});