diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cd43a3e7..c4380efc 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -28,6 +28,10 @@ jobs: run: npm ci working-directory: frontend + - name: Lint + run: npm run lint + working-directory: frontend + - name: Run unit and component tests run: npm test working-directory: frontend diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..78bf88ce --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +cd frontend && npm run lint diff --git a/frontend/.gitignore b/frontend/.gitignore index 9f121668..8bc105d9 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -28,3 +28,4 @@ src/lib/paraglide # Generated OpenAPI types — regenerate with: npm run generate:api # (committed as a stub; overwritten by the real spec after generation) # src/lib/generated/api.ts +src/lib/paraglide_bak* diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit new file mode 100644 index 00000000..72c4429b --- /dev/null +++ b/frontend/.husky/pre-commit @@ -0,0 +1 @@ +npm test diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 7d74fe24..2e8d4019 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -7,3 +7,12 @@ bun.lockb # Miscellaneous /static/ + +# Generated files +/src/lib/generated/ +/src/lib/paraglide/ +/src/lib/paraglide_bak*/ + +# Test artifacts +/test-results/ +/e2e/.auth/ diff --git a/frontend/.prettierrc b/frontend/.prettierrc index 42928dd8..f8f885f6 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -3,9 +3,7 @@ "singleQuote": true, "trailingComma": "none", "printWidth": 100, - "plugins": [ - "prettier-plugin-tailwindcss" - ], + "plugins": ["prettier-plugin-tailwindcss"], "overrides": [ { "files": "*.svelte", diff --git a/frontend/e2e/.auth/user.json b/frontend/e2e/.auth/user.json new file mode 100644 index 00000000..1398d3b8 --- /dev/null +++ b/frontend/e2e/.auth/user.json @@ -0,0 +1,25 @@ +{ + "cookies": [ + { + "name": "PARAGLIDE_LOCALE", + "value": "de", + "domain": "localhost", + "path": "/", + "expires": 1808565334.192108, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "auth_token", + "value": "Basic%20YWRtaW46YWRtaW4xMjM%3D", + "domain": "localhost", + "path": "/", + "expires": 1774091734.449243, + "httpOnly": true, + "secure": false, + "sameSite": "Strict" + } + ], + "origins": [] +} \ No newline at end of file diff --git a/frontend/e2e/lang.spec.ts b/frontend/e2e/lang.spec.ts index f3263bb7..b7475e9a 100644 --- a/frontend/e2e/lang.spec.ts +++ b/frontend/e2e/lang.spec.ts @@ -3,16 +3,24 @@ import { test, expect } from '@playwright/test'; test.describe('Language selector', () => { test('shows DE, EN, ES buttons in the header', async ({ page }) => { await page.goto('/'); - await expect(page.getByRole('banner').getByRole('button', { name: 'DE', exact: true })).toBeVisible(); - await expect(page.getByRole('banner').getByRole('button', { name: 'EN', exact: true })).toBeVisible(); - await expect(page.getByRole('banner').getByRole('button', { name: 'ES', exact: true })).toBeVisible(); + await expect( + page.getByRole('banner').getByRole('button', { name: 'DE', exact: true }) + ).toBeVisible(); + await expect( + page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }) + ).toBeVisible(); + await expect( + page.getByRole('banner').getByRole('button', { name: 'ES', exact: true }) + ).toBeVisible(); }); test('switching to EN translates the navigation', async ({ page }) => { await page.goto('/'); await page.waitForSelector('[data-hydrated]'); await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click(); - await expect(page.getByRole('navigation').getByRole('link', { name: 'Documents' })).toBeVisible(); + await expect( + page.getByRole('navigation').getByRole('link', { name: 'Documents' }) + ).toBeVisible(); await expect(page.getByRole('navigation').getByRole('link', { name: 'Persons' })).toBeVisible(); }); @@ -21,21 +29,27 @@ test.describe('Language selector', () => { await page.waitForSelector('[data-hydrated]'); await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click(); await page.goto('/persons'); - await expect(page.getByRole('navigation').getByRole('link', { name: 'Documents' })).toBeVisible(); + await expect( + page.getByRole('navigation').getByRole('link', { name: 'Documents' }) + ).toBeVisible(); }); test('switching back to DE restores German', async ({ page }) => { await page.goto('/'); await page.waitForSelector('[data-hydrated]'); await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click(); - await expect(page.getByRole('navigation').getByRole('link', { name: 'Documents' })).toBeVisible(); + await expect( + page.getByRole('navigation').getByRole('link', { name: 'Documents' }) + ).toBeVisible(); await page.getByRole('banner').getByRole('button', { name: 'DE', exact: true }).click(); // In headless Chromium, cookie deletion via document.cookie can be unreliable. // Delete the PARAGLIDE_LOCALE cookie directly so the next navigation defaults to DE. await page.context().clearCookies({ name: 'PARAGLIDE_LOCALE' }); await page.goto('/'); await page.waitForSelector('[data-hydrated]'); - await expect(page.getByRole('navigation').getByRole('link', { name: 'Dokumente' })).toBeVisible(); + await expect( + page.getByRole('navigation').getByRole('link', { name: 'Dokumente' }) + ).toBeVisible(); }); test('active language button is visually highlighted', async ({ page }) => { diff --git a/frontend/e2e/persons.spec.ts b/frontend/e2e/persons.spec.ts index 174aad5e..2fa49755 100644 --- a/frontend/e2e/persons.spec.ts +++ b/frontend/e2e/persons.spec.ts @@ -156,7 +156,9 @@ test.describe('Person detail — sent and received documents', () => { const sentHeading = page.getByRole('heading', { name: /Gesendete Dokumente/i }).locator('..'); const hasYearRange = await sentHeading.locator('span').filter({ hasText: /\d{4}/ }).count(); if (hasYearRange > 0) { - await expect(sentHeading.locator('span').filter({ hasText: /\d{4}/ }).first()).toBeVisible(); + await expect( + sentHeading.locator('span').filter({ hasText: /\d{4}/ }).first() + ).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/person-year-range.png' }); return; } @@ -166,7 +168,9 @@ test.describe('Person detail — sent and received documents', () => { }); test.describe('Person detail — conversations link', () => { - test('co-correspondent chips link to conversations pre-filled with both persons', async ({ page }) => { + test('co-correspondent chips link to conversations pre-filled with both persons', async ({ + page + }) => { await page.goto('/persons'); const firstLink = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first(); const href = await firstLink.getAttribute('href'); @@ -176,7 +180,7 @@ test.describe('Person detail — conversations link', () => { // Co-correspondent chips link to /conversations?senderId=X&receiverId=Y const chip = page.locator(`a[href^="/conversations?senderId=${personId}&receiverId="]`).first(); - if (await chip.count() > 0) { + if ((await chip.count()) > 0) { const chipHref = await chip.getAttribute('href'); expect(chipHref).toMatch(/\/conversations\?senderId=.+&receiverId=.+/); } diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index e78afbde..1b395dfb 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -21,16 +21,17 @@ export default defineConfig( languageOptions: { globals: { ...globals.browser, ...globals.node } }, - rules: { // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. - // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors - "no-undef": 'off' } + rules: { + // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. + // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + 'no-undef': 'off', + // This rule is designed for Svelte 5's own routing system using resolve(). + // In SvelteKit, and goto() from $app/navigation are the correct patterns — resolve() is not needed. + 'svelte/no-navigation-without-resolve': 'off' + } }, { - files: [ - '**/*.svelte', - '**/*.svelte.ts', - '**/*.svelte.js' - ], + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], languageOptions: { parserOptions: { projectService: true, diff --git a/frontend/package.json b/frontend/package.json index ec86be29..8485aecf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "prepare": "svelte-kit sync || echo ''", + "prepare": "svelte-kit sync || true && git -C .. config core.hooksPath .husky 2>/dev/null || true", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", diff --git a/frontend/project.inlang/settings.json b/frontend/project.inlang/settings.json index 1fc413ac..cc38db05 100644 --- a/frontend/project.inlang/settings.json +++ b/frontend/project.inlang/settings.json @@ -8,9 +8,5 @@ "pathPattern": "./messages/{locale}.json" }, "baseLocale": "de", - "locales": [ - "de", - "en", - "es" - ] + "locales": ["de", "en", "es"] } diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index a88994f9..6c1692db 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -11,7 +11,12 @@ const handleLocaleDetection: Handle = ({ event, resolve }) => { if (!event.cookies.get(cookieName)) { const locale = detectLocale(event.request.headers.get('accept-language') ?? ''); if (locale) { - event.cookies.set(cookieName, locale, { path: '/', sameSite: 'lax', maxAge: cookieMaxAge, httpOnly: false }); + event.cookies.set(cookieName, locale, { + path: '/', + sameSite: 'lax', + maxAge: cookieMaxAge, + httpOnly: false + }); } } return resolve(event); @@ -25,65 +30,63 @@ const handleAuth: Handle = async ({ event, resolve }) => { return resolve(event); }; -const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { - event.request = request; +const handleParaglide: Handle = ({ event, resolve }) => + paraglideMiddleware(event.request, ({ request, locale }) => { + event.request = request; - return resolve(event, { - transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale) + return resolve(event, { + transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale) + }); }); -}); - const userGroup: Handle = async ({ event, resolve }) => { - const auth = event.cookies.get('auth_token'); + const auth = event.cookies.get('auth_token'); - if (auth) { - try { - const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; - const response = await fetch(`${apiUrl}/api/users/me`, { - headers: { Authorization: auth } + if (auth) { + try { + const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; + const response = await fetch(`${apiUrl}/api/users/me`, { + headers: { Authorization: auth } + }); + if (response.ok) { + const user = await response.json(); + event.locals.user = user; + } + } catch (error) { + console.error('Error fetching user in hook:', error); + } + } - }); - if (response.ok) { - const user = await response.json(); - event.locals.user = user; - } - } catch (error) { - console.error('Error fetching user in hook:', error); - } - } - - return resolve(event); + return resolve(event); }; - export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { - const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; - const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/'); - const isNotLoginTest = !request.url.includes('/api/users/me'); + const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; + const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/'); + const isNotLoginTest = !request.url.includes('/api/users/me'); - if (isApi && isNotLoginTest) { - const token = event.cookies.get('auth_token'); + if (isApi && isNotLoginTest) { + const token = event.cookies.get('auth_token'); - if (!token) { - return new Response('Unauthorized', { status: 401 }); - } + if (!token) { + return new Response('Unauthorized', { status: 401 }); + } - // Clone the request first to preserve the body - const clonedRequest = request.clone(); + // Clone the request first to preserve the body + const clonedRequest = request.clone(); - // Create new request with Authorization header and preserved body - const modifiedRequest = new Request(clonedRequest, { - headers: { - ...Object.fromEntries(clonedRequest.headers), - 'Authorization': token - } - }); + // Create new request with Authorization header and preserved body + const modifiedRequest = new Request(clonedRequest, { + headers: { + ...Object.fromEntries(clonedRequest.headers), + Authorization: token + } + }); - return fetch(modifiedRequest); - } + return fetch(modifiedRequest); + } - return fetch(request); + return fetch(request); }; export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide); diff --git a/frontend/src/lib/api.server.ts b/frontend/src/lib/api.server.ts index 927509cc..126834ba 100644 --- a/frontend/src/lib/api.server.ts +++ b/frontend/src/lib/api.server.ts @@ -18,8 +18,8 @@ import { env } from '$env/dynamic/private'; import type { paths } from '$lib/generated/api'; export function createApiClient(fetch: typeof globalThis.fetch) { - return createClient({ - baseUrl: env.API_INTERNAL_URL || 'http://localhost:8080', - fetch - }); + return createClient({ + baseUrl: env.API_INTERNAL_URL || 'http://localhost:8080', + fetch + }); } diff --git a/frontend/src/lib/components/PersonMultiSelect.svelte b/frontend/src/lib/components/PersonMultiSelect.svelte index 44d2bb04..70a0ca32 100644 --- a/frontend/src/lib/components/PersonMultiSelect.svelte +++ b/frontend/src/lib/components/PersonMultiSelect.svelte @@ -1,123 +1,143 @@ -{#each selectedPersons as person} - +{#each selectedPersons as person (person.id)} + {/each}
-
- {#each selectedPersons as person} - - {person.firstName} {person.lastName} - - - {/each} +
+ {#each selectedPersons as person (person.id)} + + {person.firstName} + {person.lastName} + + + {/each} - { updateDropdownPosition(); showDropdown = true; }} - placeholder={selectedPersons.length === 0 ? m.comp_multiselect_placeholder() : ''} - class="flex-1 min-w-[120px] border-none p-1 focus:ring-0 text-sm bg-transparent outline-none" - /> -
+ { updateDropdownPosition(); showDropdown = true; }} + placeholder={selectedPersons.length === 0 ? m.comp_multiselect_placeholder() : ''} + class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0" + /> +
- {#if showDropdown && (results.length > 0 || loading)} -
- {#if loading} -
{m.comp_multiselect_loading()}
- {:else} - {#each results as person} -
selectPerson(person)} - onkeydown={(e) => e.key === 'Enter' && selectPerson(person)} - role="button" - tabindex="0" - > - {person.lastName}, {person.firstName} -
- {/each} - {/if} -
- {/if} + {#if showDropdown && (results.length > 0 || loading)} +
+ {#if loading} +
{m.comp_multiselect_loading()}
+ {:else} + {#each results as person (person.id)} +
selectPerson(person)} + onkeydown={(e) => e.key === 'Enter' && selectPerson(person)} + role="button" + tabindex="0" + > + {person.lastName}, {person.firstName} +
+ {/each} + {/if} +
+ {/if}
diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index 98cfe42d..b498e820 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -1,129 +1,126 @@
- + - + - { updateDropdownPosition(); showDropdown = true; }} - placeholder={m.comp_typeahead_placeholder()} - class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2 focus:ring-blue-500 focus:border-blue-500" - /> + { updateDropdownPosition(); showDropdown = true; }} + placeholder={m.comp_typeahead_placeholder()} + class="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-blue-500" + /> - {#if showDropdown && (results.length > 0 || loading)} -
- {#if loading} -
{m.comp_typeahead_loading()}
- {:else} - {#each results as person} -
selectPerson(person)} - onkeydown={(e) => e.key === 'Enter' && selectPerson(person)} - role="button" - tabindex="0" - > -
- - {person.lastName}, {person.firstName} - -
-
- {/each} - {/if} -
- {/if} + {#if showDropdown && (results.length > 0 || loading)} +
+ {#if loading} +
{m.comp_typeahead_loading()}
+ {:else} + {#each results as person (person.id)} +
selectPerson(person)} + onkeydown={(e) => e.key === 'Enter' && selectPerson(person)} + role="button" + tabindex="0" + > +
+ + {person.lastName}, {person.firstName} + +
+
+ {/each} + {/if} +
+ {/if}
diff --git a/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts b/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts index 40be1179..6a1ba331 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts +++ b/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; import { render } from 'vitest-browser-svelte'; -import { page, userEvent } from 'vitest/browser'; +import { page } from 'vitest/browser'; import PersonTypeahead from './PersonTypeahead.svelte'; const waitForDebounce = () => new Promise((r) => setTimeout(r, 350)); diff --git a/frontend/src/lib/components/TagInput.svelte b/frontend/src/lib/components/TagInput.svelte index 81ede1a2..fd6728a5 100644 --- a/frontend/src/lib/components/TagInput.svelte +++ b/frontend/src/lib/components/TagInput.svelte @@ -1,150 +1,154 @@
- -
- - {#each tags as tag, i} - - {tag} - - - {/each} + +
+ + {#each tags as tag, i (i)} + + {tag} + + + {/each} - -
- fetchSuggestions(inputVal)} - onkeydown={handleKeydown} - onfocus={() => fetchSuggestions(inputVal)} - placeholder={tags.length === 0 + +
+ fetchSuggestions(inputVal)} + onkeydown={handleKeydown} + onfocus={() => fetchSuggestions(inputVal)} + placeholder={tags.length === 0 ? allowCreation ? m.comp_taginput_placeholder_create() : m.comp_taginput_placeholder_filter() : ''} - class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none" - /> + class="h-full w-full border-none bg-transparent p-1 text-sm outline-none focus:ring-0" + /> - - {#if showSuggestions && suggestions.length > 0} -
    - {#each suggestions as suggestion, i} -
  • addTag(suggestion)} - onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)} - > - {suggestion} -
  • - {/each} -
- {/if} -
-
- {#if allowCreation} -

{m.comp_taginput_create_hint()}

- {/if} + onclick={() => addTag(suggestion)} + onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)} + > + {suggestion} + + {/each} + + {/if} +
+
+ {#if allowCreation} +

{m.comp_taginput_create_hint()}

+ {/if}
diff --git a/frontend/src/lib/components/TagInput.svelte.spec.ts b/frontend/src/lib/components/TagInput.svelte.spec.ts index 5c267a04..8d140aaa 100644 --- a/frontend/src/lib/components/TagInput.svelte.spec.ts +++ b/frontend/src/lib/components/TagInput.svelte.spec.ts @@ -32,17 +32,13 @@ afterEach(() => { describe('TagInput – rendering', () => { it('shows creation placeholder when allowCreation=true and no tags', async () => { render(TagInput, { tags: [], allowCreation: true }); - await expect - .element(page.getByPlaceholder('Schlagworte hinzufügen...')) - .toBeInTheDocument(); + await expect.element(page.getByPlaceholder('Schlagworte hinzufügen...')).toBeInTheDocument(); await page.screenshot({ path: 'test-results/screenshots/tag-input-empty.png' }); }); it('shows filter placeholder when allowCreation=false', async () => { render(TagInput, { tags: [], allowCreation: false }); - await expect - .element(page.getByPlaceholder('Nach Schlagworten filtern...')) - .toBeInTheDocument(); + await expect.element(page.getByPlaceholder('Nach Schlagworten filtern...')).toBeInTheDocument(); }); it('renders existing tags as chips', async () => { diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index de577eb5..e3a16bc5 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -5,20 +5,20 @@ import * as m from '$lib/paraglide/messages.js'; * Keep in sync with backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java */ export type ErrorCode = - | 'DOCUMENT_NOT_FOUND' - | 'DOCUMENT_NO_FILE' - | 'FILE_NOT_FOUND' - | 'FILE_UPLOAD_FAILED' - | 'USER_NOT_FOUND' - | 'IMPORT_ALREADY_RUNNING' - | 'UNAUTHORIZED' - | 'FORBIDDEN' - | 'VALIDATION_ERROR' - | 'INTERNAL_ERROR'; + | 'DOCUMENT_NOT_FOUND' + | 'DOCUMENT_NO_FILE' + | 'FILE_NOT_FOUND' + | 'FILE_UPLOAD_FAILED' + | 'USER_NOT_FOUND' + | 'IMPORT_ALREADY_RUNNING' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'VALIDATION_ERROR' + | 'INTERNAL_ERROR'; export interface BackendError { - code: ErrorCode; - message: string; // English developer message — not shown to users + code: ErrorCode; + message: string; // English developer message — not shown to users } /** @@ -26,29 +26,39 @@ export interface BackendError { * Returns null if the body is not valid JSON or does not contain a code field. */ export async function parseBackendError(res: Response): Promise { - try { - const body = await res.json(); - if (body && typeof body.code === 'string') { - return body as BackendError; - } - } catch { - // Body was not JSON - } - return null; + try { + const body = await res.json(); + if (body && typeof body.code === 'string') { + return body as BackendError; + } + } catch { + // Body was not JSON + } + return null; } /** Returns a localised message for the given error code via Paraglide. Falls back to INTERNAL_ERROR. */ export function getErrorMessage(code: ErrorCode | string | undefined): string { - switch (code) { - case 'DOCUMENT_NOT_FOUND': return m.error_document_not_found(); - case 'DOCUMENT_NO_FILE': return m.error_document_no_file(); - case 'FILE_NOT_FOUND': return m.error_file_not_found(); - case 'FILE_UPLOAD_FAILED': return m.error_file_upload_failed(); - case 'USER_NOT_FOUND': return m.error_user_not_found(); - case 'IMPORT_ALREADY_RUNNING':return m.error_import_already_running(); - case 'UNAUTHORIZED': return m.error_unauthorized(); - case 'FORBIDDEN': return m.error_forbidden(); - case 'VALIDATION_ERROR': return m.error_validation_error(); - default: return m.error_internal_error(); - } + switch (code) { + case 'DOCUMENT_NOT_FOUND': + return m.error_document_not_found(); + case 'DOCUMENT_NO_FILE': + return m.error_document_no_file(); + case 'FILE_NOT_FOUND': + return m.error_file_not_found(); + case 'FILE_UPLOAD_FAILED': + return m.error_file_upload_failed(); + case 'USER_NOT_FOUND': + return m.error_user_not_found(); + case 'IMPORT_ALREADY_RUNNING': + return m.error_import_already_running(); + case 'UNAUTHORIZED': + return m.error_unauthorized(); + case 'FORBIDDEN': + return m.error_forbidden(); + case 'VALIDATION_ERROR': + return m.error_validation_error(); + default: + return m.error_internal_error(); + } } diff --git a/frontend/src/lib/utils/date.ts b/frontend/src/lib/utils/date.ts index 80ff123c..9f8b5c54 100644 --- a/frontend/src/lib/utils/date.ts +++ b/frontend/src/lib/utils/date.ts @@ -3,9 +3,9 @@ * Uses T12:00:00 to avoid UTC timezone off-by-one when converting to local time. */ export function formatDate(isoDate: string): string { - return new Intl.DateTimeFormat('de-DE', { - day: 'numeric', - month: 'long', - year: 'numeric' - }).format(new Date(isoDate + 'T12:00:00')); + return new Intl.DateTimeFormat('de-DE', { + day: 'numeric', + month: 'long', + year: 'numeric' + }).format(new Date(isoDate + 'T12:00:00')); } diff --git a/frontend/src/lib/utils/sort.spec.ts b/frontend/src/lib/utils/sort.spec.ts index 488fd8d8..eebd645b 100644 --- a/frontend/src/lib/utils/sort.spec.ts +++ b/frontend/src/lib/utils/sort.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { sortDocumentsByDate } from './sort'; const doc = (id: string, documentDate: string | null) => - ({ id, documentDate } as { id: string; documentDate: string | null }); + ({ id, documentDate }) as { id: string; documentDate: string | null }; describe('sortDocumentsByDate', () => { it('sorts DESC by default — newest first', () => { diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts index 63388ed6..e703623b 100644 --- a/frontend/src/routes/+layout.server.ts +++ b/frontend/src/routes/+layout.server.ts @@ -1,8 +1,11 @@ import type { LayoutServerLoad } from './$types'; export const load: LayoutServerLoad = async ({ locals }) => { - return { - user: locals.user, - canWrite: locals.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('WRITE_ALL')) ?? false - }; -}; \ No newline at end of file + return { + user: locals.user, + canWrite: + locals.user?.groups?.some((g: { permissions: string[] }) => + g.permissions.includes('WRITE_ALL') + ) ?? false + }; +}; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 316d2ea0..0734b8f5 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,121 +1,129 @@
+ {#if !page.url.pathname.startsWith('/login')} +
+ +
- {#if !page.url.pathname.startsWith('/login')} -
- -
+
+
+ +
+ -
-
- - -
- - - -
+ ? 'rounded bg-brand-purple/15 text-brand-navy' + : 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}" + > + {m.nav_admin()} + + {/if} + +
- -
- -
- {#each locales as locale} - - {/each} -
-
- -
-
-
+ > + {locale} + + {/each} +
+
+ +
+
+
+
+ + {/if} - - - {/if} - -
- {@render children()} -
+
+ {@render children()} +
diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 991fbbb9..b9ed284a 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -2,59 +2,60 @@ import { redirect } from '@sveltejs/kit'; import { createApiClient } from '$lib/api.server'; export async function load({ url, fetch }) { - const q = url.searchParams.get('q') || ''; - const from = url.searchParams.get('from') || ''; - const to = url.searchParams.get('to') || ''; - const senderId = url.searchParams.get('senderId') || ''; - const receiverId = url.searchParams.get('receiverId') || ''; - const tags = url.searchParams.getAll('tag'); + const q = url.searchParams.get('q') || ''; + const from = url.searchParams.get('from') || ''; + const to = url.searchParams.get('to') || ''; + const senderId = url.searchParams.get('senderId') || ''; + const receiverId = url.searchParams.get('receiverId') || ''; + const tags = url.searchParams.getAll('tag'); - const api = createApiClient(fetch); + const api = createApiClient(fetch); - try { - const [docsResult, personsResult] = await Promise.all([ - api.GET('/api/documents/search', { - params: { - query: { - q: q || undefined, - from: from || undefined, - to: to || undefined, - senderId: senderId || undefined, - receiverId: receiverId || undefined, - tag: tags.length ? tags : undefined - } - } - }), - api.GET('/api/persons') - ]); + try { + const [docsResult, personsResult] = await Promise.all([ + api.GET('/api/documents/search', { + params: { + query: { + q: q || undefined, + from: from || undefined, + to: to || undefined, + senderId: senderId || undefined, + receiverId: receiverId || undefined, + tag: tags.length ? tags : undefined + } + } + }), + api.GET('/api/persons') + ]); - if (docsResult.response.status === 401 || personsResult.response.status === 401) { - throw redirect(302, '/login'); - } + if (docsResult.response.status === 401 || personsResult.response.status === 401) { + throw redirect(302, '/login'); + } - const documents = docsResult.data ?? []; - const allPersons: { id: string; firstName: string; lastName: string }[] = personsResult.data ?? []; + const documents = docsResult.data ?? []; + const allPersons: { id: string; firstName: string; lastName: string }[] = + personsResult.data ?? []; - const senderObj = allPersons.find(p => p.id === senderId); - const receiverObj = allPersons.find(p => p.id === receiverId); + const senderObj = allPersons.find((p) => p.id === senderId); + const receiverObj = allPersons.find((p) => p.id === receiverId); - return { - documents, - initialValues: { - senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '', - receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : '' - }, - filters: { q, from, to, senderId, receiverId, tags }, - error: null as string | null - }; - } catch (e) { - if ((e as { status?: number }).status) throw e; - console.error('Error loading data:', e); - return { - documents: [], - initialValues: { senderName: '', receiverName: '' }, - filters: { q, from, to, senderId, receiverId, tags }, - error: 'Daten konnten nicht geladen werden.' as string | null - }; - } + return { + documents, + initialValues: { + senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '', + receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : '' + }, + filters: { q, from, to, senderId, receiverId, tags }, + error: null as string | null + }; + } catch (e) { + if ((e as { status?: number }).status) throw e; + console.error('Error loading data:', e); + return { + documents: [], + initialValues: { senderName: '', receiverName: '' }, + filters: { q, from, to, senderId, receiverId, tags }, + error: 'Daten konnten nicht geladen werden.' as string | null + }; + } } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 55c1be94..d595f52f 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -4,6 +4,7 @@ import { goto } from '$app/navigation'; import TagInput from '$lib/components/TagInput.svelte'; import { slide } from 'svelte/transition'; import { untrack } from 'svelte'; +import { SvelteURLSearchParams } from 'svelte/reactivity'; import { m } from '$lib/paraglide/messages.js'; import { formatDate } from '$lib/utils/date'; @@ -28,7 +29,7 @@ const hasAdvancedFilters = (filters: typeof data.filters) => let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters))); function triggerSearch() { - const params = new URLSearchParams(); + const params = new SvelteURLSearchParams(); if (q) params.set('q', q); if (from) params.set('from', from); @@ -88,7 +89,12 @@ $effect(() => { class="block w-full border-gray-300 py-2.5 pr-10 pl-3 placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-brand-navy" />
- +
@@ -97,7 +103,12 @@ $effect(() => { onclick={() => (showAdvanced = !showAdvanced)} class="flex items-center gap-2 border border-gray-300 bg-gray-50 px-4 py-2.5 text-sm font-bold tracking-wide text-gray-600 uppercase transition hover:bg-gray-100 hover:text-brand-navy" > - + {m.docs_btn_filter()} @@ -107,7 +118,12 @@ $effect(() => { class="flex items-center justify-center border border-transparent px-3 py-2.5 text-gray-400 transition hover:text-red-500" title={m.docs_btn_reset_title()} > - + @@ -193,13 +209,18 @@ $effect(() => {
{#if data.canWrite} - - - {m.docs_btn_new()} - + + + {m.docs_btn_new()} + {/if}
@@ -211,7 +232,7 @@ $effect(() => { {:else if data.documents && data.documents.length > 0}