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 {m.comp_taginput_create_hint()} {m.comp_taginput_create_hint()}
- {#each suggestions as suggestion, i}
-
- {/if}
-