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.btn_delete()}
+
+
+ {m.admin_tag_delete_confirm()}
+
+
+ Gib {data.tag.name} zur Bestätigung ein:
+
+
+
+
+
+
+
+
+
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();
+ });
+});