fix(frontend): enforce lint locally and in CI, fix all pre-existing violations
## Pre-commit hook
- Add .husky/pre-commit at repo root: runs `cd frontend && npm run lint`
- Update prepare script in package.json to auto-configure git hooks path
on npm install (git -C .. config core.hooksPath .husky)
- Add lint step to CI unit-tests job so it catches issues before tests run
- Add generated dirs to .prettierignore (paraglide_bak*, test-results, .auth)
- Add src/lib/paraglide_bak* to .gitignore so ESLint can ignore them
## ESLint fixes (all pre-existing)
- Disable svelte/no-navigation-without-resolve: false positive in SvelteKit
(rule targets Svelte 5 standalone routing, not SvelteKit <a href>)
- Fix svelte/require-each-key: add (item.id)/(item) keys to all {#each} blocks
across 10 files — improves Svelte reconciliation performance
- Fix svelte/prefer-writable-derived in PersonTypeahead: $state+$effect → $derived
- Fix svelte/prefer-svelte-reactivity: URLSearchParams → SvelteURLSearchParams,
Map → SvelteMap (enables Svelte reactive tracking)
- Fix @typescript-eslint/no-unused-vars: remove dead imports/variables
## Prettier
- Run npm run format to bring all source files in line with .prettierrc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,10 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Run unit and component tests
|
- name: Run unit and component tests
|
||||||
run: npm test
|
run: npm test
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|||||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
|||||||
|
cd frontend && npm run lint
|
||||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -28,3 +28,4 @@ src/lib/paraglide
|
|||||||
# Generated OpenAPI types — regenerate with: npm run generate:api
|
# Generated OpenAPI types — regenerate with: npm run generate:api
|
||||||
# (committed as a stub; overwritten by the real spec after generation)
|
# (committed as a stub; overwritten by the real spec after generation)
|
||||||
# src/lib/generated/api.ts
|
# src/lib/generated/api.ts
|
||||||
|
src/lib/paraglide_bak*
|
||||||
|
|||||||
1
frontend/.husky/pre-commit
Normal file
1
frontend/.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npm test
|
||||||
@@ -7,3 +7,12 @@ bun.lockb
|
|||||||
|
|
||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
/static/
|
/static/
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
/src/lib/generated/
|
||||||
|
/src/lib/paraglide/
|
||||||
|
/src/lib/paraglide_bak*/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
/test-results/
|
||||||
|
/e2e/.auth/
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"plugins": [
|
"plugins": ["prettier-plugin-tailwindcss"],
|
||||||
"prettier-plugin-tailwindcss"
|
|
||||||
],
|
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": "*.svelte",
|
"files": "*.svelte",
|
||||||
|
|||||||
25
frontend/e2e/.auth/user.json
Normal file
25
frontend/e2e/.auth/user.json
Normal file
@@ -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": []
|
||||||
|
}
|
||||||
@@ -3,16 +3,24 @@ import { test, expect } from '@playwright/test';
|
|||||||
test.describe('Language selector', () => {
|
test.describe('Language selector', () => {
|
||||||
test('shows DE, EN, ES buttons in the header', async ({ page }) => {
|
test('shows DE, EN, ES buttons in the header', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('banner').getByRole('button', { name: 'DE', exact: true })).toBeVisible();
|
await expect(
|
||||||
await expect(page.getByRole('banner').getByRole('button', { name: 'EN', exact: true })).toBeVisible();
|
page.getByRole('banner').getByRole('button', { name: 'DE', exact: true })
|
||||||
await expect(page.getByRole('banner').getByRole('button', { name: 'ES', exact: true })).toBeVisible();
|
).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 }) => {
|
test('switching to EN translates the navigation', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
|
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();
|
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.waitForSelector('[data-hydrated]');
|
||||||
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
|
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
|
||||||
await page.goto('/persons');
|
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 }) => {
|
test('switching back to DE restores German', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
|
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();
|
await page.getByRole('banner').getByRole('button', { name: 'DE', exact: true }).click();
|
||||||
// In headless Chromium, cookie deletion via document.cookie can be unreliable.
|
// In headless Chromium, cookie deletion via document.cookie can be unreliable.
|
||||||
// Delete the PARAGLIDE_LOCALE cookie directly so the next navigation defaults to DE.
|
// Delete the PARAGLIDE_LOCALE cookie directly so the next navigation defaults to DE.
|
||||||
await page.context().clearCookies({ name: 'PARAGLIDE_LOCALE' });
|
await page.context().clearCookies({ name: 'PARAGLIDE_LOCALE' });
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForSelector('[data-hydrated]');
|
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 }) => {
|
test('active language button is visually highlighted', async ({ page }) => {
|
||||||
|
|||||||
@@ -156,7 +156,9 @@ test.describe('Person detail — sent and received documents', () => {
|
|||||||
const sentHeading = page.getByRole('heading', { name: /Gesendete Dokumente/i }).locator('..');
|
const sentHeading = page.getByRole('heading', { name: /Gesendete Dokumente/i }).locator('..');
|
||||||
const hasYearRange = await sentHeading.locator('span').filter({ hasText: /\d{4}/ }).count();
|
const hasYearRange = await sentHeading.locator('span').filter({ hasText: /\d{4}/ }).count();
|
||||||
if (hasYearRange > 0) {
|
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' });
|
await page.screenshot({ path: 'test-results/e2e/person-year-range.png' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -166,7 +168,9 @@ test.describe('Person detail — sent and received documents', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Person detail — conversations link', () => {
|
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');
|
await page.goto('/persons');
|
||||||
const firstLink = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
const firstLink = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
||||||
const href = await firstLink.getAttribute('href');
|
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
|
// Co-correspondent chips link to /conversations?senderId=X&receiverId=Y
|
||||||
const chip = page.locator(`a[href^="/conversations?senderId=${personId}&receiverId="]`).first();
|
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');
|
const chipHref = await chip.getAttribute('href');
|
||||||
expect(chipHref).toMatch(/\/conversations\?senderId=.+&receiverId=.+/);
|
expect(chipHref).toMatch(/\/conversations\?senderId=.+&receiverId=.+/);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,16 +21,17 @@ export default defineConfig(
|
|||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: { ...globals.browser, ...globals.node }
|
globals: { ...globals.browser, ...globals.node }
|
||||||
},
|
},
|
||||||
rules: { // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
rules: {
|
||||||
// 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
|
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||||
"no-undef": 'off' }
|
// 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, <a href> and goto() from $app/navigation are the correct patterns — resolve() is not needed.
|
||||||
|
'svelte/no-navigation-without-resolve': 'off'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: [
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
'**/*.svelte',
|
|
||||||
'**/*.svelte.ts',
|
|
||||||
'**/*.svelte.js'
|
|
||||||
],
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
projectService: true,
|
projectService: true,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"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": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
|||||||
@@ -8,9 +8,5 @@
|
|||||||
"pathPattern": "./messages/{locale}.json"
|
"pathPattern": "./messages/{locale}.json"
|
||||||
},
|
},
|
||||||
"baseLocale": "de",
|
"baseLocale": "de",
|
||||||
"locales": [
|
"locales": ["de", "en", "es"]
|
||||||
"de",
|
|
||||||
"en",
|
|
||||||
"es"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ const handleLocaleDetection: Handle = ({ event, resolve }) => {
|
|||||||
if (!event.cookies.get(cookieName)) {
|
if (!event.cookies.get(cookieName)) {
|
||||||
const locale = detectLocale(event.request.headers.get('accept-language') ?? '');
|
const locale = detectLocale(event.request.headers.get('accept-language') ?? '');
|
||||||
if (locale) {
|
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);
|
return resolve(event);
|
||||||
@@ -25,65 +30,63 @@ const handleAuth: Handle = async ({ event, resolve }) => {
|
|||||||
return resolve(event);
|
return resolve(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
|
const handleParaglide: Handle = ({ event, resolve }) =>
|
||||||
event.request = request;
|
paraglideMiddleware(event.request, ({ request, locale }) => {
|
||||||
|
event.request = request;
|
||||||
|
|
||||||
return resolve(event, {
|
return resolve(event, {
|
||||||
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
|
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const userGroup: Handle = async ({ event, resolve }) => {
|
const userGroup: Handle = async ({ event, resolve }) => {
|
||||||
const auth = event.cookies.get('auth_token');
|
const auth = event.cookies.get('auth_token');
|
||||||
|
|
||||||
if (auth) {
|
if (auth) {
|
||||||
try {
|
try {
|
||||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const response = await fetch(`${apiUrl}/api/users/me`, {
|
const response = await fetch(`${apiUrl}/api/users/me`, {
|
||||||
headers: { Authorization: auth }
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
});
|
return resolve(event);
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
||||||
const isNotLoginTest = !request.url.includes('/api/users/me');
|
const isNotLoginTest = !request.url.includes('/api/users/me');
|
||||||
|
|
||||||
if (isApi && isNotLoginTest) {
|
if (isApi && isNotLoginTest) {
|
||||||
const token = event.cookies.get('auth_token');
|
const token = event.cookies.get('auth_token');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response('Unauthorized', { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone the request first to preserve the body
|
// Clone the request first to preserve the body
|
||||||
const clonedRequest = request.clone();
|
const clonedRequest = request.clone();
|
||||||
|
|
||||||
// Create new request with Authorization header and preserved body
|
// Create new request with Authorization header and preserved body
|
||||||
const modifiedRequest = new Request(clonedRequest, {
|
const modifiedRequest = new Request(clonedRequest, {
|
||||||
headers: {
|
headers: {
|
||||||
...Object.fromEntries(clonedRequest.headers),
|
...Object.fromEntries(clonedRequest.headers),
|
||||||
'Authorization': token
|
Authorization: token
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return fetch(modifiedRequest);
|
return fetch(modifiedRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(request);
|
return fetch(request);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide);
|
export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide);
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import { env } from '$env/dynamic/private';
|
|||||||
import type { paths } from '$lib/generated/api';
|
import type { paths } from '$lib/generated/api';
|
||||||
|
|
||||||
export function createApiClient(fetch: typeof globalThis.fetch) {
|
export function createApiClient(fetch: typeof globalThis.fetch) {
|
||||||
return createClient<paths>({
|
return createClient<paths>({
|
||||||
baseUrl: env.API_INTERNAL_URL || 'http://localhost:8080',
|
baseUrl: env.API_INTERNAL_URL || 'http://localhost:8080',
|
||||||
fetch
|
fetch
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +1,143 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectedPersons?: Person[];
|
selectedPersons?: Person[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { selectedPersons = $bindable([]) }: Props = $props();
|
let { selectedPersons = $bindable([]) }: Props = $props();
|
||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
let results: Person[] = $state([]);
|
let results: Person[] = $state([]);
|
||||||
let showDropdown = $state(false);
|
let showDropdown = $state(false);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
let inputEl: HTMLInputElement;
|
let inputEl: HTMLInputElement;
|
||||||
let dropdownStyle = $state('');
|
let dropdownStyle = $state('');
|
||||||
|
|
||||||
function updateDropdownPosition() {
|
function updateDropdownPosition() {
|
||||||
if (!inputEl) return;
|
if (!inputEl) return;
|
||||||
const rect = inputEl.getBoundingClientRect();
|
const rect = inputEl.getBoundingClientRect();
|
||||||
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInput() {
|
function handleInput() {
|
||||||
showDropdown = true;
|
showDropdown = true;
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(async () => {
|
debounceTimer = setTimeout(async () => {
|
||||||
if (searchTerm.length < 1) { results = []; return; }
|
if (searchTerm.length < 1) {
|
||||||
loading = true;
|
results = [];
|
||||||
try {
|
return;
|
||||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
|
}
|
||||||
if (res.ok) {
|
loading = true;
|
||||||
const all: Person[] = await res.json();
|
try {
|
||||||
results = all.filter(p => !selectedPersons.some(s => s.id === p.id));
|
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
|
||||||
}
|
if (res.ok) {
|
||||||
} catch { results = []; }
|
const all: Person[] = await res.json();
|
||||||
finally { loading = false; }
|
results = all.filter((p) => !selectedPersons.some((s) => s.id === p.id));
|
||||||
}, 300);
|
}
|
||||||
}
|
} catch {
|
||||||
|
results = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
function selectPerson(person: Person) {
|
function selectPerson(person: Person) {
|
||||||
selectedPersons = [...selectedPersons, person];
|
selectedPersons = [...selectedPersons, person];
|
||||||
searchTerm = '';
|
searchTerm = '';
|
||||||
showDropdown = false;
|
showDropdown = false;
|
||||||
results = [];
|
results = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function removePerson(id: string | undefined) {
|
function removePerson(id: string | undefined) {
|
||||||
selectedPersons = selectedPersons.filter(p => p.id !== id);
|
selectedPersons = selectedPersons.filter((p) => p.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickOutside(node: HTMLElement) {
|
function clickOutside(node: HTMLElement) {
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||||
showDropdown = false;
|
showDropdown = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('click', handleClick, true);
|
document.addEventListener('click', handleClick, true);
|
||||||
return { destroy() { document.removeEventListener('click', handleClick, true); } };
|
return {
|
||||||
}
|
destroy() {
|
||||||
|
document.removeEventListener('click', handleClick, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||||
|
|
||||||
{#each selectedPersons as person}
|
{#each selectedPersons as person (person.id)}
|
||||||
<input type="hidden" name="receiverIds" value={person.id} />
|
<input type="hidden" name="receiverIds" value={person.id} />
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<div class="relative" use:clickOutside>
|
<div class="relative" use:clickOutside>
|
||||||
<div class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded bg-white min-h-[42px] focus-within:ring-1 focus-within:ring-brand-navy focus-within:border-brand-navy">
|
<div
|
||||||
{#each selectedPersons as person}
|
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy"
|
||||||
<span class="inline-flex items-center gap-1 bg-brand-sand/40 text-brand-navy text-sm font-medium px-2 py-1 rounded">
|
>
|
||||||
{person.firstName} {person.lastName}
|
{#each selectedPersons as person (person.id)}
|
||||||
<button
|
<span
|
||||||
type="button"
|
class="inline-flex items-center gap-1 rounded bg-brand-sand/40 px-2 py-1 text-sm font-medium text-brand-navy"
|
||||||
onclick={() => removePerson(person.id)}
|
>
|
||||||
class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5"
|
{person.firstName}
|
||||||
aria-label={m.comp_multiselect_remove()}
|
{person.lastName}
|
||||||
>
|
<button
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
type="button"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
onclick={() => removePerson(person.id)}
|
||||||
</svg>
|
class="ml-0.5 text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
||||||
</button>
|
aria-label={m.comp_multiselect_remove()}
|
||||||
</span>
|
>
|
||||||
{/each}
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
bind:value={searchTerm}
|
bind:value={searchTerm}
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||||
placeholder={selectedPersons.length === 0 ? m.comp_multiselect_placeholder() : ''}
|
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"
|
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showDropdown && (results.length > 0 || loading)}
|
{#if showDropdown && (results.length > 0 || loading)}
|
||||||
<div
|
<div
|
||||||
style={dropdownStyle}
|
style={dropdownStyle}
|
||||||
class="z-50 bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm"
|
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
|
||||||
>
|
>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="p-2 text-gray-500 text-sm">{m.comp_multiselect_loading()}</div>
|
<div class="p-2 text-sm text-gray-500">{m.comp_multiselect_loading()}</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each results as person}
|
{#each results as person (person.id)}
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-brand-sand/30 text-gray-900"
|
class="cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-brand-sand/30"
|
||||||
onclick={() => selectPerson(person)}
|
onclick={() => selectPerson(person)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
{person.lastName}, {person.firstName}
|
{person.lastName}, {person.firstName}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,129 +1,126 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
initialName?: string;
|
initialName?: string;
|
||||||
onchange?: (value: string) => void;
|
onchange?: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { name, label, value = $bindable(''), initialName = '', onchange }: Props = $props();
|
let { name, label, value = $bindable(''), initialName = '', onchange }: Props = $props();
|
||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $derived(initialName);
|
||||||
|
|
||||||
// Sync with external changes (e.g. reset button) — also sets the initial value
|
let results: Person[] = $state([]);
|
||||||
$effect(() => {
|
let showDropdown = $state(false);
|
||||||
searchTerm = initialName;
|
let loading = $state(false);
|
||||||
});
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
let results: Person[] = $state([]);
|
function handleInput() {
|
||||||
let showDropdown = $state(false);
|
if (value && searchTerm !== initialName) {
|
||||||
let loading = $state(false);
|
value = '';
|
||||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
onchange?.('');
|
||||||
|
}
|
||||||
|
|
||||||
function handleInput() {
|
showDropdown = true;
|
||||||
if (value && searchTerm !== initialName) {
|
clearTimeout(debounceTimer);
|
||||||
value = '';
|
|
||||||
onchange?.('');
|
|
||||||
}
|
|
||||||
|
|
||||||
showDropdown = true;
|
debounceTimer = setTimeout(async () => {
|
||||||
clearTimeout(debounceTimer);
|
if (searchTerm.length < 1) {
|
||||||
|
results = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
|
||||||
|
results = res.ok ? await res.json() : [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Suche fehlgeschlagen', e);
|
||||||
|
results = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
debounceTimer = setTimeout(async () => {
|
function selectPerson(person: Person) {
|
||||||
if (searchTerm.length < 1) {
|
value = person.id!;
|
||||||
results = [];
|
searchTerm = `${person.firstName} ${person.lastName}`;
|
||||||
return;
|
showDropdown = false;
|
||||||
}
|
onchange?.(person.id!);
|
||||||
loading = true;
|
}
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
|
|
||||||
results = res.ok ? await res.json() : [];
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Suche fehlgeschlagen', e);
|
|
||||||
results = [];
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectPerson(person: Person) {
|
let inputEl: HTMLInputElement;
|
||||||
value = person.id!;
|
let dropdownStyle = $state('');
|
||||||
searchTerm = `${person.firstName} ${person.lastName}`;
|
|
||||||
showDropdown = false;
|
|
||||||
onchange?.(person.id!);
|
|
||||||
}
|
|
||||||
|
|
||||||
let inputEl: HTMLInputElement;
|
function updateDropdownPosition() {
|
||||||
let dropdownStyle = $state('');
|
if (!inputEl) return;
|
||||||
|
const rect = inputEl.getBoundingClientRect();
|
||||||
|
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
||||||
|
}
|
||||||
|
|
||||||
function updateDropdownPosition() {
|
function clickOutside(node: HTMLElement) {
|
||||||
if (!inputEl) return;
|
const handleClick = (event: MouseEvent) => {
|
||||||
const rect = inputEl.getBoundingClientRect();
|
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||||
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
showDropdown = false;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
function clickOutside(node: HTMLElement) {
|
document.addEventListener('click', handleClick, true);
|
||||||
const handleClick = (event: MouseEvent) => {
|
return {
|
||||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
destroy() {
|
||||||
showDropdown = false;
|
document.removeEventListener('click', handleClick, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('click', handleClick, true);
|
}
|
||||||
return {
|
|
||||||
destroy() { document.removeEventListener('click', handleClick, true); }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||||
|
|
||||||
<div class="relative" use:clickOutside>
|
<div class="relative" use:clickOutside>
|
||||||
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
|
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
|
||||||
|
|
||||||
<input type="hidden" {name} bind:value={value} />
|
<input type="hidden" name={name} bind:value={value} />
|
||||||
|
|
||||||
<input
|
<input
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
type="text"
|
type="text"
|
||||||
id="{name}-search"
|
id="{name}-search"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
bind:value={searchTerm}
|
bind:value={searchTerm}
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||||
placeholder={m.comp_typeahead_placeholder()}
|
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"
|
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 showDropdown && (results.length > 0 || loading)}
|
||||||
<div
|
<div
|
||||||
style={dropdownStyle}
|
style={dropdownStyle}
|
||||||
class="z-50 bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
|
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||||
>
|
>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="p-2 text-gray-500 text-sm">{m.comp_typeahead_loading()}</div>
|
<div class="p-2 text-sm text-gray-500">{m.comp_typeahead_loading()}</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each results as person}
|
{#each results as person (person.id)}
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-blue-100 text-gray-900"
|
class="relative cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-blue-100"
|
||||||
onclick={() => selectPerson(person)}
|
onclick={() => selectPerson(person)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-medium block truncate">
|
<span class="block truncate font-medium">
|
||||||
{person.lastName}, {person.firstName}
|
{person.lastName}, {person.firstName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
import { render } from 'vitest-browser-svelte';
|
import { render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import PersonTypeahead from './PersonTypeahead.svelte';
|
import PersonTypeahead from './PersonTypeahead.svelte';
|
||||||
|
|
||||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||||
|
|||||||
@@ -1,150 +1,154 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
allowCreation?: boolean;
|
allowCreation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { tags = $bindable([]), allowCreation = true }: Props = $props();
|
let { tags = $bindable([]), allowCreation = true }: Props = $props();
|
||||||
|
|
||||||
let inputVal = $state('');
|
let inputVal = $state('');
|
||||||
let suggestions: string[] = $state([]);
|
let suggestions: string[] = $state([]);
|
||||||
let activeIndex = $state(-1);
|
let activeIndex = $state(-1);
|
||||||
let showSuggestions = $state(false);
|
let showSuggestions = $state(false);
|
||||||
|
|
||||||
async function fetchSuggestions(query: string) {
|
async function fetchSuggestions(query: string) {
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
suggestions = [];
|
suggestions = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const names: string[] = data.map((t: { name: string }) => t.name);
|
const names: string[] = data.map((t: { name: string }) => t.name);
|
||||||
suggestions = names.filter((t) => !tags.includes(t));
|
suggestions = names.filter((t) => !tags.includes(t));
|
||||||
showSuggestions = true;
|
showSuggestions = true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Tag fetch error', e);
|
console.error('Tag fetch error', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTag(tag: string) {
|
function addTag(tag: string) {
|
||||||
const trimmed = tag.trim();
|
const trimmed = tag.trim();
|
||||||
if (trimmed && !tags.includes(trimmed)) {
|
if (trimmed && !tags.includes(trimmed)) {
|
||||||
tags = [...tags, trimmed];
|
tags = [...tags, trimmed];
|
||||||
}
|
}
|
||||||
inputVal = '';
|
inputVal = '';
|
||||||
suggestions = [];
|
suggestions = [];
|
||||||
showSuggestions = false;
|
showSuggestions = false;
|
||||||
activeIndex = -1;
|
activeIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTag(index: number) {
|
function removeTag(index: number) {
|
||||||
tags = tags.filter((_, i) => i !== index);
|
tags = tags.filter((_, i) => i !== index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (activeIndex >= 0 && suggestions[activeIndex]) {
|
if (activeIndex >= 0 && suggestions[activeIndex]) {
|
||||||
addTag(suggestions[activeIndex]);
|
addTag(suggestions[activeIndex]);
|
||||||
} else if (allowCreation) {
|
} else if (allowCreation) {
|
||||||
addTag(inputVal);
|
addTag(inputVal);
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
|
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
|
||||||
removeTag(tags.length - 1);
|
removeTag(tags.length - 1);
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
activeIndex = (activeIndex + 1) % suggestions.length;
|
activeIndex = (activeIndex + 1) % suggestions.length;
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickOutside(node: HTMLElement) {
|
function clickOutside(node: HTMLElement) {
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||||
showSuggestions = false;
|
showSuggestions = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('click', handleClick, true);
|
document.addEventListener('click', handleClick, true);
|
||||||
return { destroy() { document.removeEventListener('click', handleClick, true); } };
|
return {
|
||||||
}
|
destroy() {
|
||||||
|
document.removeEventListener('click', handleClick, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full" use:clickOutside>
|
<div class="w-full" use:clickOutside>
|
||||||
<!-- Tag Container -->
|
<!-- Tag Container -->
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded focus-within:ring-1 focus-within:ring-brand-navy focus-within:border-brand-navy bg-white min-h-[42px]"
|
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy"
|
||||||
>
|
>
|
||||||
<!-- Render Selected Tags -->
|
<!-- Render Selected Tags -->
|
||||||
{#each tags as tag, i}
|
{#each tags as tag, i (i)}
|
||||||
<span
|
<span
|
||||||
class="bg-brand-sand/30 text-brand-navy text-sm font-medium px-2 py-1 rounded flex items-center gap-1"
|
class="flex items-center gap-1 rounded bg-brand-sand/30 px-2 py-1 text-sm font-medium text-brand-navy"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => removeTag(i)}
|
onclick={() => removeTag(i)}
|
||||||
aria-label={m.comp_taginput_remove()}
|
aria-label={m.comp_taginput_remove()}
|
||||||
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="M6 18L18 6M6 6l12 12"
|
d="M6 18L18 6M6 6l12 12"
|
||||||
/></svg
|
/></svg
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Input Field -->
|
<!-- Input Field -->
|
||||||
<div class="relative flex-1 min-w-[120px]">
|
<div class="relative min-w-[120px] flex-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={inputVal}
|
bind:value={inputVal}
|
||||||
oninput={() => fetchSuggestions(inputVal)}
|
oninput={() => fetchSuggestions(inputVal)}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
onfocus={() => fetchSuggestions(inputVal)}
|
onfocus={() => fetchSuggestions(inputVal)}
|
||||||
placeholder={tags.length === 0
|
placeholder={tags.length === 0
|
||||||
? allowCreation
|
? allowCreation
|
||||||
? m.comp_taginput_placeholder_create()
|
? m.comp_taginput_placeholder_create()
|
||||||
: m.comp_taginput_placeholder_filter()
|
: 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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Typeahead Dropdown -->
|
<!-- Typeahead Dropdown -->
|
||||||
{#if showSuggestions && suggestions.length > 0}
|
{#if showSuggestions && suggestions.length > 0}
|
||||||
<ul
|
<ul
|
||||||
class="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded shadow-lg z-50 max-h-48 overflow-y-auto"
|
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-gray-200 bg-white shadow-lg"
|
||||||
>
|
>
|
||||||
{#each suggestions as suggestion, i}
|
{#each suggestions as suggestion, i (i)}
|
||||||
<li
|
<li
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === activeIndex}
|
aria-selected={i === activeIndex}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="px-3 py-2 text-sm cursor-pointer hover:bg-brand-sand/20 {i === activeIndex
|
class="cursor-pointer px-3 py-2 text-sm hover:bg-brand-sand/20 {i === activeIndex
|
||||||
? 'bg-brand-sand/20 text-brand-navy font-bold'
|
? 'bg-brand-sand/20 font-bold text-brand-navy'
|
||||||
: 'text-gray-700'}"
|
: 'text-gray-700'}"
|
||||||
onclick={() => addTag(suggestion)}
|
onclick={() => addTag(suggestion)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
||||||
>
|
>
|
||||||
{suggestion}
|
{suggestion}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if allowCreation}
|
{#if allowCreation}
|
||||||
<p class="text-xs text-gray-400 mt-1">{m.comp_taginput_create_hint()}</p>
|
<p class="mt-1 text-xs text-gray-400">{m.comp_taginput_create_hint()}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,17 +32,13 @@ afterEach(() => {
|
|||||||
describe('TagInput – rendering', () => {
|
describe('TagInput – rendering', () => {
|
||||||
it('shows creation placeholder when allowCreation=true and no tags', async () => {
|
it('shows creation placeholder when allowCreation=true and no tags', async () => {
|
||||||
render(TagInput, { tags: [], allowCreation: true });
|
render(TagInput, { tags: [], allowCreation: true });
|
||||||
await expect
|
await expect.element(page.getByPlaceholder('Schlagworte hinzufügen...')).toBeInTheDocument();
|
||||||
.element(page.getByPlaceholder('Schlagworte hinzufügen...'))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-empty.png' });
|
await page.screenshot({ path: 'test-results/screenshots/tag-input-empty.png' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows filter placeholder when allowCreation=false', async () => {
|
it('shows filter placeholder when allowCreation=false', async () => {
|
||||||
render(TagInput, { tags: [], allowCreation: false });
|
render(TagInput, { tags: [], allowCreation: false });
|
||||||
await expect
|
await expect.element(page.getByPlaceholder('Nach Schlagworten filtern...')).toBeInTheDocument();
|
||||||
.element(page.getByPlaceholder('Nach Schlagworten filtern...'))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders existing tags as chips', async () => {
|
it('renders existing tags as chips', async () => {
|
||||||
|
|||||||
@@ -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
|
* Keep in sync with backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
|
||||||
*/
|
*/
|
||||||
export type ErrorCode =
|
export type ErrorCode =
|
||||||
| 'DOCUMENT_NOT_FOUND'
|
| 'DOCUMENT_NOT_FOUND'
|
||||||
| 'DOCUMENT_NO_FILE'
|
| 'DOCUMENT_NO_FILE'
|
||||||
| 'FILE_NOT_FOUND'
|
| 'FILE_NOT_FOUND'
|
||||||
| 'FILE_UPLOAD_FAILED'
|
| 'FILE_UPLOAD_FAILED'
|
||||||
| 'USER_NOT_FOUND'
|
| 'USER_NOT_FOUND'
|
||||||
| 'IMPORT_ALREADY_RUNNING'
|
| 'IMPORT_ALREADY_RUNNING'
|
||||||
| 'UNAUTHORIZED'
|
| 'UNAUTHORIZED'
|
||||||
| 'FORBIDDEN'
|
| 'FORBIDDEN'
|
||||||
| 'VALIDATION_ERROR'
|
| 'VALIDATION_ERROR'
|
||||||
| 'INTERNAL_ERROR';
|
| 'INTERNAL_ERROR';
|
||||||
|
|
||||||
export interface BackendError {
|
export interface BackendError {
|
||||||
code: ErrorCode;
|
code: ErrorCode;
|
||||||
message: string; // English developer message — not shown to users
|
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.
|
* Returns null if the body is not valid JSON or does not contain a code field.
|
||||||
*/
|
*/
|
||||||
export async function parseBackendError(res: Response): Promise<BackendError | null> {
|
export async function parseBackendError(res: Response): Promise<BackendError | null> {
|
||||||
try {
|
try {
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (body && typeof body.code === 'string') {
|
if (body && typeof body.code === 'string') {
|
||||||
return body as BackendError;
|
return body as BackendError;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Body was not JSON
|
// Body was not JSON
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns a localised message for the given error code via Paraglide. Falls back to INTERNAL_ERROR. */
|
/** Returns a localised message for the given error code via Paraglide. Falls back to INTERNAL_ERROR. */
|
||||||
export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case 'DOCUMENT_NOT_FOUND': return m.error_document_not_found();
|
case 'DOCUMENT_NOT_FOUND':
|
||||||
case 'DOCUMENT_NO_FILE': return m.error_document_no_file();
|
return m.error_document_not_found();
|
||||||
case 'FILE_NOT_FOUND': return m.error_file_not_found();
|
case 'DOCUMENT_NO_FILE':
|
||||||
case 'FILE_UPLOAD_FAILED': return m.error_file_upload_failed();
|
return m.error_document_no_file();
|
||||||
case 'USER_NOT_FOUND': return m.error_user_not_found();
|
case 'FILE_NOT_FOUND':
|
||||||
case 'IMPORT_ALREADY_RUNNING':return m.error_import_already_running();
|
return m.error_file_not_found();
|
||||||
case 'UNAUTHORIZED': return m.error_unauthorized();
|
case 'FILE_UPLOAD_FAILED':
|
||||||
case 'FORBIDDEN': return m.error_forbidden();
|
return m.error_file_upload_failed();
|
||||||
case 'VALIDATION_ERROR': return m.error_validation_error();
|
case 'USER_NOT_FOUND':
|
||||||
default: return m.error_internal_error();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
* Uses T12:00:00 to avoid UTC timezone off-by-one when converting to local time.
|
* Uses T12:00:00 to avoid UTC timezone off-by-one when converting to local time.
|
||||||
*/
|
*/
|
||||||
export function formatDate(isoDate: string): string {
|
export function formatDate(isoDate: string): string {
|
||||||
return new Intl.DateTimeFormat('de-DE', {
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
}).format(new Date(isoDate + 'T12:00:00'));
|
}).format(new Date(isoDate + 'T12:00:00'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { sortDocumentsByDate } from './sort';
|
import { sortDocumentsByDate } from './sort';
|
||||||
|
|
||||||
const doc = (id: string, documentDate: string | null) =>
|
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', () => {
|
describe('sortDocumentsByDate', () => {
|
||||||
it('sorts DESC by default — newest first', () => {
|
it('sorts DESC by default — newest first', () => {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
return {
|
return {
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
canWrite: locals.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('WRITE_ALL')) ?? false
|
canWrite:
|
||||||
};
|
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||||
};
|
g.permissions.includes('WRITE_ALL')
|
||||||
|
) ?? false
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,121 +1,129 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
const locales = ['DE', 'EN', 'ES'] as const;
|
const locales = ['DE', 'EN', 'ES'] as const;
|
||||||
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
|
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
|
||||||
const activeLocale = $derived(getLocale().toUpperCase());
|
const activeLocale = $derived(getLocale().toUpperCase());
|
||||||
|
|
||||||
const isAdmin = $derived(page.data.user?.groups.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN')));
|
const isAdmin = $derived(
|
||||||
|
page.data.user?.groups.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'))
|
||||||
|
);
|
||||||
|
|
||||||
// Set after client-side hydration completes. Used by E2E tests to know the
|
// Set after client-side hydration completes. Used by E2E tests to know the
|
||||||
// page is interactive (event handlers registered) before they interact with it.
|
// page is interactive (event handlers registered) before they interact with it.
|
||||||
let hydrated = $state(false);
|
let hydrated = $state(false);
|
||||||
onMount(() => { hydrated = true; });
|
onMount(() => {
|
||||||
|
hydrated = true;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-white" data-hydrated={hydrated || undefined}>
|
<div class="min-h-screen bg-white" data-hydrated={hydrated || undefined}>
|
||||||
|
{#if !page.url.pathname.startsWith('/login')}
|
||||||
|
<header class="sticky top-0 z-50 border-b border-gray-100 bg-white">
|
||||||
|
<!-- De Gruyter Brill purple accent strip -->
|
||||||
|
<div class="h-1 bg-brand-purple"></div>
|
||||||
|
|
||||||
{#if !page.url.pathname.startsWith('/login')}
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<header class="bg-white border-b border-gray-100 sticky top-0 z-50">
|
<div class="flex h-16 justify-between">
|
||||||
<!-- De Gruyter Brill purple accent strip -->
|
<!-- Logo & Nav -->
|
||||||
<div class="h-1 bg-brand-purple"></div>
|
<div class="flex">
|
||||||
|
<div class="mr-10 flex flex-shrink-0 items-center">
|
||||||
|
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
||||||
|
<span class="font-sans text-xl font-bold tracking-widest text-brand-navy uppercase"
|
||||||
|
>Familienarchiv</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<nav class="hidden items-center sm:flex sm:space-x-1">
|
||||||
<div class="flex justify-between h-16">
|
<a
|
||||||
|
href="/"
|
||||||
<!-- Logo & Nav -->
|
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||||
<div class="flex">
|
|
||||||
<div class="flex-shrink-0 flex items-center mr-10">
|
|
||||||
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
|
||||||
<span class="font-sans font-bold text-xl tracking-widest text-brand-navy uppercase">Familienarchiv</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="hidden sm:flex sm:space-x-1 items-center">
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
|
|
||||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||||
? 'text-brand-navy bg-brand-purple/15 rounded'
|
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||||
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
|
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||||
>
|
>
|
||||||
{m.nav_documents()}
|
{m.nav_documents()}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/persons"
|
href="/persons"
|
||||||
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
|
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||||
{page.url.pathname.startsWith('/persons')
|
{page.url.pathname.startsWith('/persons')
|
||||||
? 'text-brand-navy bg-brand-purple/15 rounded'
|
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||||
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
|
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||||
>
|
>
|
||||||
{m.nav_persons()}
|
{m.nav_persons()}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/conversations"
|
href="/conversations"
|
||||||
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
|
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||||
{page.url.pathname.startsWith('/conversations')
|
{page.url.pathname.startsWith('/conversations')
|
||||||
? 'text-brand-navy bg-brand-purple/15 rounded'
|
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||||
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
|
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||||
>
|
>
|
||||||
{m.nav_conversations()}
|
{m.nav_conversations()}
|
||||||
</a>
|
</a>
|
||||||
{#if isAdmin}
|
{#if isAdmin}
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
|
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||||
{page.url.pathname.startsWith('/admin')
|
{page.url.pathname.startsWith('/admin')
|
||||||
? 'text-brand-navy bg-brand-purple/15 rounded'
|
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||||
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
|
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||||
>
|
>
|
||||||
{m.nav_admin()}
|
{m.nav_admin()}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Side -->
|
<!-- Right Side -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- Language selector -->
|
<!-- Language selector -->
|
||||||
<div class="flex items-center gap-1 border-r border-gray-200 pr-3">
|
<div class="flex items-center gap-1 border-r border-gray-200 pr-3">
|
||||||
{#each locales as locale}
|
{#each locales as locale (locale)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => setLocale(localeMap[locale])}
|
onclick={() => setLocale(localeMap[locale])}
|
||||||
class="text-xs font-sans tracking-widest px-1.5 py-1 transition-colors
|
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
|
||||||
{activeLocale === locale
|
{activeLocale === locale
|
||||||
? 'font-bold text-brand-navy'
|
? 'font-bold text-brand-navy'
|
||||||
: 'font-normal text-gray-400 hover:text-brand-navy'}"
|
: 'font-normal text-gray-400 hover:text-brand-navy'}"
|
||||||
>
|
>
|
||||||
{locale}
|
{locale}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<form action="/logout" method="POST" use:enhance>
|
<form action="/logout" method="POST" use:enhance>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="inline-flex items-center gap-1.5 text-xs text-gray-400 hover:text-brand-navy font-bold uppercase font-sans tracking-widest px-3 py-2 transition-colors"
|
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
|
||||||
>
|
>
|
||||||
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg" alt="" aria-hidden="true" class="w-4 h-4 opacity-50" />
|
<img
|
||||||
{m.nav_logout()}
|
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
|
||||||
</button>
|
alt=""
|
||||||
</form>
|
aria-hidden="true"
|
||||||
</div>
|
class="h-4 w-4 opacity-50"
|
||||||
</div>
|
/>
|
||||||
|
{m.nav_logout()}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{/if}
|
||||||
|
|
||||||
</div>
|
<main class="py-6">
|
||||||
</header>
|
{@render children()}
|
||||||
{/if}
|
</main>
|
||||||
|
|
||||||
<main class="py-6">
|
|
||||||
{@render children()}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,59 +2,60 @@ import { redirect } from '@sveltejs/kit';
|
|||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
|
||||||
export async function load({ url, fetch }) {
|
export async function load({ url, fetch }) {
|
||||||
const q = url.searchParams.get('q') || '';
|
const q = url.searchParams.get('q') || '';
|
||||||
const from = url.searchParams.get('from') || '';
|
const from = url.searchParams.get('from') || '';
|
||||||
const to = url.searchParams.get('to') || '';
|
const to = url.searchParams.get('to') || '';
|
||||||
const senderId = url.searchParams.get('senderId') || '';
|
const senderId = url.searchParams.get('senderId') || '';
|
||||||
const receiverId = url.searchParams.get('receiverId') || '';
|
const receiverId = url.searchParams.get('receiverId') || '';
|
||||||
const tags = url.searchParams.getAll('tag');
|
const tags = url.searchParams.getAll('tag');
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [docsResult, personsResult] = await Promise.all([
|
const [docsResult, personsResult] = await Promise.all([
|
||||||
api.GET('/api/documents/search', {
|
api.GET('/api/documents/search', {
|
||||||
params: {
|
params: {
|
||||||
query: {
|
query: {
|
||||||
q: q || undefined,
|
q: q || undefined,
|
||||||
from: from || undefined,
|
from: from || undefined,
|
||||||
to: to || undefined,
|
to: to || undefined,
|
||||||
senderId: senderId || undefined,
|
senderId: senderId || undefined,
|
||||||
receiverId: receiverId || undefined,
|
receiverId: receiverId || undefined,
|
||||||
tag: tags.length ? tags : undefined
|
tag: tags.length ? tags : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
api.GET('/api/persons')
|
api.GET('/api/persons')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
|
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
|
||||||
throw redirect(302, '/login');
|
throw redirect(302, '/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const documents = docsResult.data ?? [];
|
const documents = docsResult.data ?? [];
|
||||||
const allPersons: { id: string; firstName: string; lastName: string }[] = personsResult.data ?? [];
|
const allPersons: { id: string; firstName: string; lastName: string }[] =
|
||||||
|
personsResult.data ?? [];
|
||||||
|
|
||||||
const senderObj = allPersons.find(p => p.id === senderId);
|
const senderObj = allPersons.find((p) => p.id === senderId);
|
||||||
const receiverObj = allPersons.find(p => p.id === receiverId);
|
const receiverObj = allPersons.find((p) => p.id === receiverId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents,
|
documents,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
||||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
||||||
},
|
},
|
||||||
filters: { q, from, to, senderId, receiverId, tags },
|
filters: { q, from, to, senderId, receiverId, tags },
|
||||||
error: null as string | null
|
error: null as string | null
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ((e as { status?: number }).status) throw e;
|
if ((e as { status?: number }).status) throw e;
|
||||||
console.error('Error loading data:', e);
|
console.error('Error loading data:', e);
|
||||||
return {
|
return {
|
||||||
documents: [],
|
documents: [],
|
||||||
initialValues: { senderName: '', receiverName: '' },
|
initialValues: { senderName: '', receiverName: '' },
|
||||||
filters: { q, from, to, senderId, receiverId, tags },
|
filters: { q, from, to, senderId, receiverId, tags },
|
||||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { goto } from '$app/navigation';
|
|||||||
import TagInput from '$lib/components/TagInput.svelte';
|
import TagInput from '$lib/components/TagInput.svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ const hasAdvancedFilters = (filters: typeof data.filters) =>
|
|||||||
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
|
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
|
||||||
|
|
||||||
function triggerSearch() {
|
function triggerSearch() {
|
||||||
const params = new URLSearchParams();
|
const params = new SvelteURLSearchParams();
|
||||||
|
|
||||||
if (q) params.set('q', q);
|
if (q) params.set('q', q);
|
||||||
if (from) params.set('from', from);
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg" alt="" aria-hidden="true" class="h-4 w-4 opacity-40" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 opacity-40"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -97,7 +103,12 @@ $effect(() => {
|
|||||||
onclick={() => (showAdvanced = !showAdvanced)}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg" alt="" aria-hidden="true" class="h-4 w-4 transform transition-transform duration-200 {showAdvanced ? 'rotate-180' : ''}" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 transform transition-transform duration-200 {showAdvanced ? 'rotate-180' : ''}"
|
||||||
|
/>
|
||||||
{m.docs_btn_filter()}
|
{m.docs_btn_filter()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -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"
|
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()}
|
title={m.docs_btn_reset_title()}
|
||||||
>
|
>
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Close-MD.svg" alt="" aria-hidden="true" class="h-5 w-5 opacity-40" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Close-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5 opacity-40"
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,13 +209,18 @@ $effect(() => {
|
|||||||
<!-- DOCUMENT LIST HEADER -->
|
<!-- DOCUMENT LIST HEADER -->
|
||||||
<div class="mb-2 flex justify-end">
|
<div class="mb-2 flex justify-end">
|
||||||
{#if data.canWrite}
|
{#if data.canWrite}
|
||||||
<a
|
<a
|
||||||
href="/documents/new"
|
href="/documents/new"
|
||||||
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
||||||
>
|
>
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg" alt="" aria-hidden="true" class="h-4 w-4" />
|
<img
|
||||||
{m.docs_btn_new()}
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||||
</a>
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{m.docs_btn_new()}
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,7 +232,7 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
{:else if data.documents && data.documents.length > 0}
|
{:else if data.documents && data.documents.length > 0}
|
||||||
<ul class="divide-y divide-gray-100">
|
<ul class="divide-y divide-gray-100">
|
||||||
{#each data.documents as doc}
|
{#each data.documents as doc (doc.id)}
|
||||||
<li class="group transition-colors duration-200 hover:bg-brand-sand/10">
|
<li class="group transition-colors duration-200 hover:bg-brand-sand/10">
|
||||||
<!-- LINK TO DETAIL PAGE -->
|
<!-- LINK TO DETAIL PAGE -->
|
||||||
<a href="/documents/{doc.id}" class="block p-6">
|
<a href="/documents/{doc.id}" class="block p-6">
|
||||||
@@ -240,12 +261,22 @@ $effect(() => {
|
|||||||
<!-- Metadata Row -->
|
<!-- Metadata Row -->
|
||||||
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-gray-500">
|
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-gray-500">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg" alt="" aria-hidden="true" class="mr-1.5 h-4 w-4" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="mr-1.5 h-4 w-4"
|
||||||
|
/>
|
||||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||||
</div>
|
</div>
|
||||||
{#if doc.location}
|
{#if doc.location}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg" alt="" aria-hidden="true" class="mr-1.5 h-4 w-4" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="mr-1.5 h-4 w-4"
|
||||||
|
/>
|
||||||
{doc.location}
|
{doc.location}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -284,7 +315,7 @@ $effect(() => {
|
|||||||
<!-- Tags Display -->
|
<!-- Tags Display -->
|
||||||
{#if doc.tags && doc.tags.length > 0}
|
{#if doc.tags && doc.tags.length > 0}
|
||||||
<div class="mt-4 flex flex-wrap gap-2 pt-3">
|
<div class="mt-4 flex flex-wrap gap-2 pt-3">
|
||||||
{#each doc.tags as tag}
|
{#each doc.tags as tag (tag.id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative z-10 inline-flex cursor-pointer items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
class="relative z-10 inline-flex cursor-pointer items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
||||||
@@ -301,7 +332,12 @@ $effect(() => {
|
|||||||
<div
|
<div
|
||||||
class="hidden items-center text-gray-300 transition-colors group-hover:text-brand-mint sm:flex"
|
class="hidden items-center text-gray-300 transition-colors group-hover:text-brand-mint sm:flex"
|
||||||
>
|
>
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg" alt="" aria-hidden="true" class="h-6 w-6" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-6 w-6"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -314,7 +350,12 @@ $effect(() => {
|
|||||||
<div
|
<div
|
||||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-sand/30"
|
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-sand/30"
|
||||||
>
|
>
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg" alt="" aria-hidden="true" class="h-6 w-6" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-6 w-6"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="font-serif text-lg font-medium text-brand-navy">{m.docs_empty_heading()}</h3>
|
<h3 class="font-serif text-lg font-medium text-brand-navy">{m.docs_empty_heading()}</h3>
|
||||||
<p class="mt-1 font-sans text-sm text-gray-500">
|
<p class="mt-1 font-sans text-sm text-gray-500">
|
||||||
|
|||||||
@@ -5,125 +5,127 @@ import { getErrorMessage } from '$lib/errors';
|
|||||||
type ApiResult = { response: Response; error?: unknown };
|
type ApiResult = { response: Response; error?: unknown };
|
||||||
|
|
||||||
function toActionResult(result: ApiResult) {
|
function toActionResult(result: ApiResult) {
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
const code = (result.error as { code?: string } | undefined)?.code;
|
const code = (result.error as { code?: string } | undefined)?.code;
|
||||||
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
|
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
|
||||||
}
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function load({ fetch, locals }) {
|
export async function load({ fetch, locals }) {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
const hasAdmin = user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'));
|
const hasAdmin = user?.groups?.some((g: { permissions: string[] }) =>
|
||||||
if (!hasAdmin) throw error(403, getErrorMessage('FORBIDDEN'));
|
g.permissions.includes('ADMIN')
|
||||||
|
);
|
||||||
|
if (!hasAdmin) throw error(403, getErrorMessage('FORBIDDEN'));
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const [usersResult, groupsResult, tagsResult] = await Promise.all([
|
const [usersResult, groupsResult, tagsResult] = await Promise.all([
|
||||||
api.GET('/api/users'),
|
api.GET('/api/users'),
|
||||||
api.GET('/api/groups'),
|
api.GET('/api/groups'),
|
||||||
api.GET('/api/tags')
|
api.GET('/api/tags')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
users: usersResult.data ?? [],
|
users: usersResult.data ?? [],
|
||||||
groups: groupsResult.data ?? [],
|
groups: groupsResult.data ?? [],
|
||||||
tags: tagsResult.data ?? []
|
tags: tagsResult.data ?? []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
createUser: async ({ request, fetch }) => {
|
createUser: async ({ request, fetch }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const result = await api.POST('/api/users', {
|
const result = await api.POST('/api/users', {
|
||||||
body: {
|
body: {
|
||||||
username: data.get('username') as string,
|
username: data.get('username') as string,
|
||||||
initialPassword: data.get('password') as string,
|
initialPassword: data.get('password') as string,
|
||||||
groupIds: data.getAll('groupIds') as string[]
|
groupIds: data.getAll('groupIds') as string[]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return toActionResult(result);
|
return toActionResult(result);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteUser: async ({ request, fetch }) => {
|
deleteUser: async ({ request, fetch }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const id = data.get('id') as string;
|
const id = data.get('id') as string;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const result = await api.DELETE('/api/users/{id}', {
|
const result = await api.DELETE('/api/users/{id}', {
|
||||||
params: { path: { id } }
|
params: { path: { id } }
|
||||||
});
|
});
|
||||||
|
|
||||||
return toActionResult(result);
|
return toActionResult(result);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTag: async ({ request, fetch }) => {
|
updateTag: async ({ request, fetch }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const id = data.get('id') as string;
|
const id = data.get('id') as string;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const result = await api.PUT('/api/tags/{id}', {
|
const result = await api.PUT('/api/tags/{id}', {
|
||||||
params: { path: { id } },
|
params: { path: { id } },
|
||||||
body: { name: data.get('name') as string }
|
body: { name: data.get('name') as string }
|
||||||
});
|
});
|
||||||
|
|
||||||
return toActionResult(result);
|
return toActionResult(result);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTag: async ({ request, fetch }) => {
|
deleteTag: async ({ request, fetch }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const id = data.get('id') as string;
|
const id = data.get('id') as string;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const result = await api.DELETE('/api/tags/{id}', {
|
const result = await api.DELETE('/api/tags/{id}', {
|
||||||
params: { path: { id } }
|
params: { path: { id } }
|
||||||
});
|
});
|
||||||
|
|
||||||
return toActionResult(result);
|
return toActionResult(result);
|
||||||
},
|
},
|
||||||
|
|
||||||
createGroup: async ({ request, fetch }) => {
|
createGroup: async ({ request, fetch }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const result = await api.POST('/api/groups', {
|
const result = await api.POST('/api/groups', {
|
||||||
body: {
|
body: {
|
||||||
name: data.get('name') as string,
|
name: data.get('name') as string,
|
||||||
permissions: data.getAll('permissions') as string[]
|
permissions: data.getAll('permissions') as string[]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return toActionResult(result);
|
return toActionResult(result);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateGroup: async ({ request, fetch }) => {
|
updateGroup: async ({ request, fetch }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const id = data.get('id') as string;
|
const id = data.get('id') as string;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const result = await api.PATCH('/api/groups/{id}', {
|
const result = await api.PATCH('/api/groups/{id}', {
|
||||||
params: { path: { id } },
|
params: { path: { id } },
|
||||||
body: {
|
body: {
|
||||||
name: data.get('name') as string,
|
name: data.get('name') as string,
|
||||||
permissions: data.getAll('permissions') as string[]
|
permissions: data.getAll('permissions') as string[]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return toActionResult(result);
|
return toActionResult(result);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteGroup: async ({ request, fetch }) => {
|
deleteGroup: async ({ request, fetch }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const id = data.get('id') as string;
|
const id = data.get('id') as string;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const result = await api.DELETE('/api/groups/{id}', {
|
const result = await api.DELETE('/api/groups/{id}', {
|
||||||
params: { path: { id } }
|
params: { path: { id } }
|
||||||
});
|
});
|
||||||
|
|
||||||
return toActionResult(result);
|
return toActionResult(result);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,67 +1,67 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
let activeTab = $state('users');
|
let activeTab = $state('users');
|
||||||
let editingTagId: string | null = $state(null);
|
let editingTagId: string | null = $state(null);
|
||||||
let editingTagName = $state('');
|
let editingTagName = $state('');
|
||||||
let editingUserId: string | null = $state(null);
|
let editingUserId: string | null = $state(null);
|
||||||
let editingGroupId: string | null = $state(null);
|
let editingGroupId: string | null = $state(null);
|
||||||
|
|
||||||
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
||||||
|
|
||||||
function startEditTag(tag: { id: string; name: string }) {
|
function startEditTag(tag: { id: string; name: string }) {
|
||||||
editingTagId = tag.id;
|
editingTagId = tag.id;
|
||||||
editingTagName = tag.name;
|
editingTagName = tag.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEditTag() {
|
function cancelEditTag() {
|
||||||
editingTagId = null;
|
editingTagId = null;
|
||||||
editingTagName = '';
|
editingTagName = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEditUser(id: string) {
|
function startEditUser(id: string) {
|
||||||
editingUserId = id;
|
editingUserId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEditUser() {
|
function cancelEditUser() {
|
||||||
editingUserId = null;
|
editingUserId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEditGroup(id: string) {
|
function startEditGroup(id: string) {
|
||||||
editingGroupId = id;
|
editingGroupId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEditGroup() {
|
function cancelEditGroup() {
|
||||||
editingGroupId = null;
|
editingGroupId = null;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto py-8 sm:px-6 lg:px-8 font-sans">
|
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="mb-8 flex items-center justify-between">
|
||||||
<h1 class="text-3xl font-serif text-brand-navy">{m.admin_heading()}</h1>
|
<h1 class="font-serif text-3xl text-brand-navy">{m.admin_heading()}</h1>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="flex bg-white rounded-lg p-1 shadow-sm border border-gray-200">
|
<div class="flex rounded-lg border border-gray-200 bg-white p-1 shadow-sm">
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
|
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
||||||
'users'
|
'users'
|
||||||
? 'bg-brand-navy text-white'
|
? 'bg-brand-navy text-white'
|
||||||
: 'text-gray-500 hover:text-brand-navy'}"
|
: 'text-gray-500 hover:text-brand-navy'}"
|
||||||
onclick={() => (activeTab = 'users')}>{m.admin_tab_users()}</button
|
onclick={() => (activeTab = 'users')}>{m.admin_tab_users()}</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
|
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
||||||
'groups'
|
'groups'
|
||||||
? 'bg-brand-navy text-white'
|
? 'bg-brand-navy text-white'
|
||||||
: 'text-gray-500 hover:text-brand-navy'}"
|
: 'text-gray-500 hover:text-brand-navy'}"
|
||||||
onclick={() => (activeTab = 'groups')}>{m.admin_tab_groups()}</button
|
onclick={() => (activeTab = 'groups')}>{m.admin_tab_groups()}</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
|
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
||||||
'tags'
|
'tags'
|
||||||
? 'bg-brand-navy text-white'
|
? 'bg-brand-navy text-white'
|
||||||
: 'text-gray-500 hover:text-brand-navy'}"
|
: 'text-gray-500 hover:text-brand-navy'}"
|
||||||
@@ -71,40 +71,40 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if form?.message}
|
{#if form?.message}
|
||||||
<div class="bg-brand-mint/20 text-brand-navy p-4 rounded mb-6 border border-brand-mint/50">
|
<div class="mb-6 rounded border border-brand-mint/50 bg-brand-mint/20 p-4 text-brand-navy">
|
||||||
{form.message}
|
{form.message}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if activeTab === 'users'}
|
{#if activeTab === 'users'}
|
||||||
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
|
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
|
||||||
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
|
<div class="flex items-center justify-between border-b border-gray-100 p-6">
|
||||||
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_users()}</h2>
|
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_users()}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
|
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||||
>{m.admin_col_login()}</th
|
>{m.admin_col_login()}</th
|
||||||
>
|
>
|
||||||
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
|
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||||
>{m.admin_col_groups()}</th
|
>{m.admin_col_groups()}</th
|
||||||
>
|
>
|
||||||
{#if editingUserId}
|
{#if editingUserId}
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider"
|
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||||
>{m.admin_col_password()}</th
|
>{m.admin_col_password()}</th
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
{#each data.users as user}
|
{#each data.users as user (user.id)}
|
||||||
<tr class="group/row hover:bg-gray-50">
|
<tr class="group/row hover:bg-gray-50">
|
||||||
{#if editingUserId === user.id}
|
{#if editingUserId === user.id}
|
||||||
<!-- === EDIT MODE === -->
|
<!-- === EDIT MODE === -->
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||||
{user.username}
|
{user.username}
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
@@ -119,9 +119,9 @@
|
|||||||
name="groupIds"
|
name="groupIds"
|
||||||
multiple
|
multiple
|
||||||
form="edit-form-{user.id}"
|
form="edit-form-{user.id}"
|
||||||
class="block w-full rounded border-brand-mint text-xs p-1 min-h-[80px]"
|
class="block min-h-[80px] w-full rounded border-brand-mint p-1 text-xs"
|
||||||
>
|
>
|
||||||
{#each data.groups as group}
|
{#each data.groups as group (group.id)}
|
||||||
<option
|
<option
|
||||||
value={group.id}
|
value={group.id}
|
||||||
selected={user.groups.some((g: { id: string }) => g.id === group.id)}
|
selected={user.groups.some((g: { id: string }) => g.id === group.id)}
|
||||||
@@ -130,10 +130,10 @@
|
|||||||
</option>
|
</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<p class="text-[10px] text-gray-400 mt-1">{m.admin_multiselect_hint()}</p>
|
<p class="mt-1 text-[10px] text-gray-400">{m.admin_multiselect_hint()}</p>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right align-top">
|
<td class="px-6 py-4 text-right align-top whitespace-nowrap">
|
||||||
<form
|
<form
|
||||||
id="edit-form-{user.id}"
|
id="edit-form-{user.id}"
|
||||||
method="POST"
|
method="POST"
|
||||||
@@ -149,20 +149,20 @@
|
|||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder={m.admin_password_placeholder()}
|
placeholder={m.admin_password_placeholder()}
|
||||||
class="w-32 py-1 px-2 text-xs border border-brand-mint rounded"
|
class="w-32 rounded border border-brand-mint px-2 py-1 text-xs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex gap-2 mt-1">
|
<div class="mt-1 flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="bg-green-600 text-white px-2 py-1 rounded text-xs font-bold uppercase hover:bg-green-700"
|
class="rounded bg-green-600 px-2 py-1 text-xs font-bold text-white uppercase hover:bg-green-700"
|
||||||
>
|
>
|
||||||
{m.btn_save()}
|
{m.btn_save()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={cancelEditUser}
|
onclick={cancelEditUser}
|
||||||
class="bg-gray-200 text-gray-600 px-2 py-1 rounded text-xs font-bold uppercase hover:bg-gray-300"
|
class="rounded bg-gray-200 px-2 py-1 text-xs font-bold text-gray-600 uppercase hover:bg-gray-300"
|
||||||
>
|
>
|
||||||
{m.btn_cancel()}
|
{m.btn_cancel()}
|
||||||
</button>
|
</button>
|
||||||
@@ -171,15 +171,15 @@
|
|||||||
</td>
|
</td>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- === VIEW MODE === -->
|
<!-- === VIEW MODE === -->
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||||
{user.username}
|
{user.username}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm text-gray-500">
|
<td class="px-6 py-4 text-sm text-gray-500">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#if user.groups && user.groups.length > 0}
|
{#if user.groups && user.groups.length > 0}
|
||||||
{#each user.groups as group}
|
{#each user.groups as group (group.id)}
|
||||||
<span
|
<span
|
||||||
class="px-2 py-0.5 text-[10px] font-bold uppercase rounded-full bg-blue-50 text-blue-700 border border-blue-100"
|
class="rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 uppercase"
|
||||||
>
|
>
|
||||||
{group.name}
|
{group.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -189,11 +189,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||||
<div class="flex items-center justify-end gap-4">
|
<div class="flex items-center justify-end gap-4">
|
||||||
<button
|
<button
|
||||||
onclick={() => startEditUser(user.id)}
|
onclick={() => startEditUser(user.id)}
|
||||||
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
|
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
|
||||||
>
|
>
|
||||||
{m.btn_edit()}
|
{m.btn_edit()}
|
||||||
</button>
|
</button>
|
||||||
@@ -213,10 +213,10 @@
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={user.id} />
|
<input type="hidden" name="id" value={user.id} />
|
||||||
<button
|
<button
|
||||||
class="text-gray-300 hover:text-red-600 transition-colors p-1"
|
class="p-1 text-gray-300 transition-colors hover:text-red-600"
|
||||||
title={m.admin_btn_delete_user_title()}
|
title={m.admin_btn_delete_user_title()}
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
@@ -235,66 +235,66 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Create User Form -->
|
<!-- Create User Form -->
|
||||||
<div class="p-6 bg-gray-50 border-t border-gray-200">
|
<div class="border-t border-gray-200 bg-gray-50 p-6">
|
||||||
<h3 class="text-xs font-bold uppercase text-gray-500 mb-4 tracking-wide">
|
<h3 class="mb-4 text-xs font-bold tracking-wide text-gray-500 uppercase">
|
||||||
{m.admin_section_new_user()}
|
{m.admin_section_new_user()}
|
||||||
</h3>
|
</h3>
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/createUser"
|
action="?/createUser"
|
||||||
use:enhance
|
use:enhance
|
||||||
class="grid grid-cols-1 md:grid-cols-6 gap-4 items-start"
|
class="grid grid-cols-1 items-start gap-4 md:grid-cols-6"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="username"
|
name="username"
|
||||||
placeholder="Login"
|
placeholder="Login"
|
||||||
required
|
required
|
||||||
class="rounded border-gray-300 text-sm w-full"
|
class="w-full rounded border-gray-300 text-sm"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder={m.admin_col_password()}
|
placeholder={m.admin_col_password()}
|
||||||
required
|
required
|
||||||
class="rounded border-gray-300 text-sm w-full"
|
class="w-full rounded border-gray-300 text-sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="md:col-span-3">
|
<div class="md:col-span-3">
|
||||||
<select
|
<select
|
||||||
name="groupIds"
|
name="groupIds"
|
||||||
multiple
|
multiple
|
||||||
class="rounded border-gray-300 text-sm w-full h-[42px] py-1"
|
class="h-[42px] w-full rounded border-gray-300 py-1 text-sm"
|
||||||
required
|
required
|
||||||
title={m.admin_multiselect_hint_multi()}
|
title={m.admin_multiselect_hint_multi()}
|
||||||
>
|
>
|
||||||
{#each data.groups as group}
|
{#each data.groups as group (group.id)}
|
||||||
<option value={group.id}>{group.name}</option>
|
<option value={group.id}>{group.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<p class="text-[10px] text-gray-400 mt-1">{m.admin_multiselect_hint_full()}</p>
|
<p class="mt-1 text-[10px] text-gray-400">{m.admin_multiselect_hint_full()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="bg-brand-navy text-white h-[42px] rounded text-sm font-bold uppercase hover:bg-brand-mint hover:text-brand-navy w-full"
|
class="h-[42px] w-full rounded bg-brand-navy text-sm font-bold text-white uppercase hover:bg-brand-mint hover:text-brand-navy"
|
||||||
>{m.btn_create()}</button
|
>{m.btn_create()}</button
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if activeTab === 'tags'}
|
{:else if activeTab === 'tags'}
|
||||||
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
|
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
|
||||||
<div class="p-6 border-b border-gray-100 bg-yellow-50/50">
|
<div class="border-b border-gray-100 bg-yellow-50/50 p-6">
|
||||||
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_tags()}</h2>
|
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_tags()}</h2>
|
||||||
<p class="text-xs text-yellow-800 mt-1">
|
<p class="mt-1 text-xs text-yellow-800">
|
||||||
{m.admin_tags_warning()}
|
{m.admin_tags_warning()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="divide-y divide-gray-100 max-h-[600px] overflow-y-auto">
|
<ul class="max-h-[600px] divide-y divide-gray-100 overflow-y-auto">
|
||||||
{#each data.tags as tag}
|
{#each data.tags as tag (tag.id)}
|
||||||
<li class="px-6 py-3 flex items-center justify-between hover:bg-gray-50 group">
|
<li class="group flex items-center justify-between px-6 py-3 hover:bg-gray-50">
|
||||||
{#if editingTagId === tag.id}
|
{#if editingTagId === tag.id}
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
@@ -304,17 +304,17 @@
|
|||||||
await update();
|
await update();
|
||||||
cancelEditTag();
|
cancelEditTag();
|
||||||
}}
|
}}
|
||||||
class="flex-1 flex gap-2 items-center"
|
class="flex flex-1 items-center gap-2"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={tag.id} />
|
<input type="hidden" name="id" value={tag.id} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
bind:value={editingTagName}
|
bind:value={editingTagName}
|
||||||
class="flex-1 border-brand-mint ring-1 ring-brand-mint rounded px-2 py-1 text-sm"
|
class="flex-1 rounded border-brand-mint px-2 py-1 text-sm ring-1 ring-brand-mint"
|
||||||
/>
|
/>
|
||||||
<button aria-label={m.btn_save()} class="text-green-600 hover:text-green-800"
|
<button aria-label={m.btn_save()} class="text-green-600 hover:text-green-800"
|
||||||
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
@@ -328,7 +328,7 @@
|
|||||||
onclick={cancelEditTag}
|
onclick={cancelEditTag}
|
||||||
aria-label={m.btn_cancel()}
|
aria-label={m.btn_cancel()}
|
||||||
class="text-gray-400 hover:text-gray-600"
|
class="text-gray-400 hover:text-gray-600"
|
||||||
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
@@ -339,18 +339,18 @@
|
|||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-sm font-medium text-brand-navy bg-brand-sand/30 px-2 py-1 rounded">
|
<span class="rounded bg-brand-sand/30 px-2 py-1 text-sm font-medium text-brand-navy">
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
class="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onclick={() => startEditTag(tag)}
|
onclick={() => startEditTag(tag)}
|
||||||
aria-label={m.admin_btn_edit_tag_label()}
|
aria-label={m.admin_btn_edit_tag_label()}
|
||||||
class="p-1 text-gray-400 hover:text-brand-navy"
|
class="p-1 text-gray-400 hover:text-brand-navy"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
@@ -375,8 +375,11 @@
|
|||||||
class="inline"
|
class="inline"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={tag.id} />
|
<input type="hidden" name="id" value={tag.id} />
|
||||||
<button aria-label={m.admin_btn_delete_tag_label()} class="p-1 text-gray-400 hover:text-red-600">
|
<button
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
aria-label={m.admin_btn_delete_tag_label()}
|
||||||
|
class="p-1 text-gray-400 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
@@ -393,28 +396,28 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{:else if activeTab === 'groups'}
|
{:else if activeTab === 'groups'}
|
||||||
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
|
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
|
||||||
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
|
<div class="flex items-center justify-between border-b border-gray-100 p-6">
|
||||||
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_groups()}</h2>
|
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_groups()}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
|
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||||
>{m.admin_col_name()}</th
|
>{m.admin_col_name()}</th
|
||||||
>
|
>
|
||||||
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
|
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||||
>{m.admin_col_permissions()}</th
|
>{m.admin_col_permissions()}</th
|
||||||
>
|
>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider"
|
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||||
>{m.admin_col_actions()}</th
|
>{m.admin_col_actions()}</th
|
||||||
>
|
>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
{#each data.groups as group}
|
{#each data.groups as group (group.id)}
|
||||||
<tr class="group/row hover:bg-gray-50">
|
<tr class="group/row hover:bg-gray-50">
|
||||||
{#if editingGroupId === group.id}
|
{#if editingGroupId === group.id}
|
||||||
<!-- EDIT MODE -->
|
<!-- EDIT MODE -->
|
||||||
@@ -427,7 +430,7 @@
|
|||||||
await update();
|
await update();
|
||||||
cancelEditGroup();
|
cancelEditGroup();
|
||||||
}}
|
}}
|
||||||
class="flex flex-col sm:flex-row items-start gap-4 w-full"
|
class="flex w-full flex-col items-start gap-4 sm:flex-row"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={group.id} />
|
<input type="hidden" name="id" value={group.id} />
|
||||||
|
|
||||||
@@ -436,13 +439,13 @@
|
|||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
value={group.name}
|
value={group.name}
|
||||||
class="w-full text-sm border-brand-mint rounded"
|
class="w-full rounded border-brand-mint text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 flex flex-wrap gap-4 items-center h-full pt-2">
|
<div class="flex h-full flex-1 flex-wrap items-center gap-4 pt-2">
|
||||||
{#each availablePermissions as perm}
|
{#each availablePermissions as perm (perm)}
|
||||||
<label
|
<label
|
||||||
class="inline-flex items-center text-xs font-bold text-gray-600 uppercase"
|
class="inline-flex items-center text-xs font-bold text-gray-600 uppercase"
|
||||||
>
|
>
|
||||||
@@ -451,7 +454,7 @@
|
|||||||
name="permissions"
|
name="permissions"
|
||||||
value={perm}
|
value={perm}
|
||||||
checked={group.permissions.includes(perm)}
|
checked={group.permissions.includes(perm)}
|
||||||
class="mr-2 text-brand-navy focus:ring-brand-mint rounded border-gray-300"
|
class="mr-2 rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
|
||||||
/>
|
/>
|
||||||
{perm.replace('_', ' ')}
|
{perm.replace('_', ' ')}
|
||||||
</label>
|
</label>
|
||||||
@@ -459,8 +462,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 self-start sm:self-center">
|
<div class="flex gap-2 self-start sm:self-center">
|
||||||
<button type="submit" aria-label={m.btn_save()} class="text-green-600 hover:text-green-800 p-1">
|
<button
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
type="submit"
|
||||||
|
aria-label={m.btn_save()}
|
||||||
|
class="p-1 text-green-600 hover:text-green-800"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
@@ -473,9 +480,9 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={cancelEditGroup}
|
onclick={cancelEditGroup}
|
||||||
aria-label={m.btn_cancel()}
|
aria-label={m.btn_cancel()}
|
||||||
class="text-gray-400 hover:text-red-500 p-1"
|
class="p-1 text-gray-400 hover:text-red-500"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
@@ -489,28 +496,28 @@
|
|||||||
</td>
|
</td>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- VIEW MODE -->
|
<!-- VIEW MODE -->
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-bold text-brand-navy">
|
<td class="px-6 py-4 text-sm font-bold whitespace-nowrap text-brand-navy">
|
||||||
{group.name}
|
{group.name}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm text-gray-500">
|
<td class="px-6 py-4 text-sm text-gray-500">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#each group.permissions as perm}
|
{#each group.permissions as perm (perm)}
|
||||||
<span
|
<span
|
||||||
class="px-2 py-0.5 text-[10px] font-bold uppercase rounded-full
|
class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase
|
||||||
{perm === 'ADMIN'
|
{perm === 'ADMIN'
|
||||||
? 'bg-red-50 text-red-700 border-red-100'
|
? 'border-red-100 bg-red-50 text-red-700'
|
||||||
: 'bg-gray-100 text-gray-600 border-gray-200'}"
|
: 'border-gray-200 bg-gray-100 text-gray-600'}"
|
||||||
>
|
>
|
||||||
{perm}
|
{perm}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||||
<div class="flex items-center justify-end gap-3">
|
<div class="flex items-center justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onclick={() => startEditGroup(group.id)}
|
onclick={() => startEditGroup(group.id)}
|
||||||
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
|
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
|
||||||
>
|
>
|
||||||
{m.btn_edit()}
|
{m.btn_edit()}
|
||||||
</button>
|
</button>
|
||||||
@@ -529,10 +536,10 @@
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={group.id} />
|
<input type="hidden" name="id" value={group.id} />
|
||||||
<button
|
<button
|
||||||
class="text-gray-300 hover:text-red-600 p-1 transition-colors"
|
class="p-1 text-gray-300 transition-colors hover:text-red-600"
|
||||||
title={m.btn_delete()}
|
title={m.btn_delete()}
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
@@ -551,34 +558,34 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- CREATE GROUP FORM -->
|
<!-- CREATE GROUP FORM -->
|
||||||
<div class="p-6 bg-gray-50 border-t border-gray-200">
|
<div class="border-t border-gray-200 bg-gray-50 p-6">
|
||||||
<h3 class="text-xs font-bold uppercase text-gray-500 mb-4 tracking-wide">
|
<h3 class="mb-4 text-xs font-bold tracking-wide text-gray-500 uppercase">
|
||||||
{m.admin_section_new_group()}
|
{m.admin_section_new_group()}
|
||||||
</h3>
|
</h3>
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/createGroup"
|
action="?/createGroup"
|
||||||
use:enhance
|
use:enhance
|
||||||
class="flex flex-col md:flex-row gap-4 items-start md:items-center"
|
class="flex flex-col items-start gap-4 md:flex-row md:items-center"
|
||||||
>
|
>
|
||||||
<div class="flex-1 w-full">
|
<div class="w-full flex-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder={m.admin_group_name_placeholder()}
|
placeholder={m.admin_group_name_placeholder()}
|
||||||
required
|
required
|
||||||
class="rounded border-gray-300 text-sm w-full"
|
class="w-full rounded border-gray-300 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-4 items-center">
|
<div class="flex items-center gap-4">
|
||||||
{#each availablePermissions as perm}
|
{#each availablePermissions as perm (perm)}
|
||||||
<label class="inline-flex items-center text-xs font-bold text-gray-600 uppercase">
|
<label class="inline-flex items-center text-xs font-bold text-gray-600 uppercase">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="permissions"
|
name="permissions"
|
||||||
value={perm}
|
value={perm}
|
||||||
class="mr-2 text-brand-navy focus:ring-brand-mint rounded border-gray-300"
|
class="mr-2 rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
|
||||||
/>
|
/>
|
||||||
{perm.replace('_', ' ')}
|
{perm.replace('_', ' ')}
|
||||||
</label>
|
</label>
|
||||||
@@ -587,7 +594,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="bg-brand-navy text-white px-6 py-2 rounded text-sm font-bold uppercase hover:bg-brand-mint hover:text-brand-navy w-full md:w-auto"
|
class="w-full rounded bg-brand-navy px-6 py-2 text-sm font-bold text-white uppercase hover:bg-brand-mint hover:text-brand-navy md:w-auto"
|
||||||
>
|
>
|
||||||
{m.btn_create()}
|
{m.btn_create()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ import type { RequestHandler } from './$types';
|
|||||||
import { env } from 'process';
|
import { env } from 'process';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, fetch }) => {
|
export const GET: RequestHandler = async ({ params, fetch }) => {
|
||||||
const backendUrl = `${env.API_INTERNAL_URL || 'http://localhost:8080'}/api/documents/${params.id}/file`;
|
const backendUrl = `${env.API_INTERNAL_URL || 'http://localhost:8080'}/api/documents/${params.id}/file`;
|
||||||
|
|
||||||
const response = await fetch(backendUrl);
|
const response = await fetch(backendUrl);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return new Response(null, { status: response.status });
|
return new Response(null, { status: response.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(response.body, {
|
return new Response(response.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': response.headers.get('Content-Type') ?? 'application/octet-stream',
|
'Content-Type': response.headers.get('Content-Type') ?? 'application/octet-stream',
|
||||||
'Content-Disposition': response.headers.get('Content-Disposition') ?? ''
|
'Content-Disposition': response.headers.get('Content-Disposition') ?? ''
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,33 +3,32 @@ import type { RequestHandler } from './$types';
|
|||||||
import { env } from 'process';
|
import { env } from 'process';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, fetch }) => {
|
export const GET: RequestHandler = async ({ url, fetch }) => {
|
||||||
// 1. Suchparameter aus der URL des Browsers holen
|
// 1. Suchparameter aus der URL des Browsers holen
|
||||||
const q = url.searchParams.get('q') || '';
|
const q = url.searchParams.get('q') || '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 3. Anfrage an das Java-Backend weiterleiten (Server-to-Server)
|
// 3. Anfrage an das Java-Backend weiterleiten (Server-to-Server)
|
||||||
// Wir nutzen hier den internen Docker-Hostnamen oder localhost, je nach Netzwerk
|
// Wir nutzen hier den internen Docker-Hostnamen oder localhost, je nach Netzwerk
|
||||||
const backendUrl = `${env.API_INTERNAL_URL || 'http://localhost:8080'}/api/persons?q=${encodeURIComponent(q)}`;
|
const backendUrl = `${env.API_INTERNAL_URL || 'http://localhost:8080'}/api/persons?q=${encodeURIComponent(q)}`;
|
||||||
|
|
||||||
const response = await fetch(backendUrl, {
|
const response = await fetch(backendUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error(`Backend Error: ${response.status}`);
|
console.error(`Backend Error: ${response.status}`);
|
||||||
return json([], { status: response.status });
|
return json([], { status: response.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// 4. Daten zurück an den Browser schicken
|
// 4. Daten zurück an den Browser schicken
|
||||||
return json(data);
|
return json(data);
|
||||||
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error('Proxy Error:', error);
|
||||||
console.error("Proxy Error:", error);
|
return json([], { status: 500 });
|
||||||
return json([], { status: 500 });
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,34 +3,33 @@ import type { RequestHandler } from './$types';
|
|||||||
import { env } from 'process';
|
import { env } from 'process';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, fetch }) => {
|
export const GET: RequestHandler = async ({ url, fetch }) => {
|
||||||
// 1. Suchparameter aus der URL des Browsers holen
|
// 1. Suchparameter aus der URL des Browsers holen
|
||||||
const q = url.searchParams.get('q') || '';
|
const q = url.searchParams.get('q') || '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 3. Anfrage an das Java-Backend weiterleiten (Server-to-Server)
|
// 3. Anfrage an das Java-Backend weiterleiten (Server-to-Server)
|
||||||
// Wir nutzen hier den internen Docker-Hostnamen oder localhost, je nach Netzwerk
|
// Wir nutzen hier den internen Docker-Hostnamen oder localhost, je nach Netzwerk
|
||||||
const backendUrl = `${env.API_INTERNAL_URL || 'http://localhost:8080'}/api/tags?q=${encodeURIComponent(q)}`;
|
const backendUrl = `${env.API_INTERNAL_URL || 'http://localhost:8080'}/api/tags?q=${encodeURIComponent(q)}`;
|
||||||
|
|
||||||
const response = await fetch(backendUrl, {
|
const response = await fetch(backendUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error(`Backend Error: ${response.status}`);
|
console.error(`Backend Error: ${response.status}`);
|
||||||
return json([], { status: response.status });
|
return json([], { status: response.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log("Tags Data", data)
|
console.log('Tags Data', data);
|
||||||
|
|
||||||
// 4. Daten zurück an den Browser schicken
|
// 4. Daten zurück an den Browser schicken
|
||||||
return json(data);
|
return json(data);
|
||||||
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error('Proxy Error:', error);
|
||||||
console.error("Proxy Error:", error);
|
return json([], { status: 500 });
|
||||||
return json([], { status: 500 });
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,61 +2,63 @@ import type { components } from '$lib/generated/api';
|
|||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
|
||||||
export async function load({ url, fetch }) {
|
export async function load({ url, fetch }) {
|
||||||
const senderId = url.searchParams.get('senderId') || '';
|
const senderId = url.searchParams.get('senderId') || '';
|
||||||
const receiverId = url.searchParams.get('receiverId') || '';
|
const receiverId = url.searchParams.get('receiverId') || '';
|
||||||
const from = url.searchParams.get('from') || '';
|
const from = url.searchParams.get('from') || '';
|
||||||
const to = url.searchParams.get('to') || '';
|
const to = url.searchParams.get('to') || '';
|
||||||
const dir = url.searchParams.get('dir') || 'DESC';
|
const dir = url.searchParams.get('dir') || 'DESC';
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
let documents: components['schemas']['Document'][] = [];
|
let documents: components['schemas']['Document'][] = [];
|
||||||
let senderName = '';
|
let senderName = '';
|
||||||
let receiverName = '';
|
let receiverName = '';
|
||||||
|
|
||||||
const requests: Promise<void>[] = [];
|
const requests: Promise<void>[] = [];
|
||||||
|
|
||||||
if (senderId && receiverId) {
|
if (senderId && receiverId) {
|
||||||
requests.push(
|
requests.push(
|
||||||
api.GET('/api/documents/conversation', {
|
api
|
||||||
params: {
|
.GET('/api/documents/conversation', {
|
||||||
query: {
|
params: {
|
||||||
senderId,
|
query: {
|
||||||
receiverId,
|
senderId,
|
||||||
dir,
|
receiverId,
|
||||||
from: from || undefined,
|
dir,
|
||||||
to: to || undefined
|
from: from || undefined,
|
||||||
}
|
to: to || undefined
|
||||||
}
|
}
|
||||||
}).then(({ data }) => { documents = data ?? []; })
|
}
|
||||||
);
|
})
|
||||||
}
|
.then(({ data }) => {
|
||||||
|
documents = data ?? [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (senderId) {
|
if (senderId) {
|
||||||
requests.push(
|
requests.push(
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } })
|
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
|
||||||
.then(({ data }) => {
|
const p = data as { firstName: string; lastName: string } | undefined;
|
||||||
const p = data as { firstName: string; lastName: string } | undefined;
|
if (p) senderName = `${p.firstName} ${p.lastName}`;
|
||||||
if (p) senderName = `${p.firstName} ${p.lastName}`;
|
})
|
||||||
})
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (receiverId) {
|
if (receiverId) {
|
||||||
requests.push(
|
requests.push(
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } })
|
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
|
||||||
.then(({ data }) => {
|
const p = data as { firstName: string; lastName: string } | undefined;
|
||||||
const p = data as { firstName: string; lastName: string } | undefined;
|
if (p) receiverName = `${p.firstName} ${p.lastName}`;
|
||||||
if (p) receiverName = `${p.firstName} ${p.lastName}`;
|
})
|
||||||
})
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(requests);
|
await Promise.all(requests);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents,
|
documents,
|
||||||
initialValues: { senderName, receiverName },
|
initialValues: { senderName, receiverName },
|
||||||
filters: { senderId, receiverId, from, to, dir }
|
filters: { senderId, receiverId, from, to, dir }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,59 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||||
import { formatDate } from '$lib/utils/date';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { formatDate } from '$lib/utils/date';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let senderId = $state(untrack(() => data.filters.senderId));
|
let senderId = $state(untrack(() => data.filters.senderId));
|
||||||
let receiverId = $state(untrack(() => data.filters.receiverId));
|
let receiverId = $state(untrack(() => data.filters.receiverId));
|
||||||
let fromDate = $state(untrack(() => data.filters.from));
|
let fromDate = $state(untrack(() => data.filters.from));
|
||||||
let toDate = $state(untrack(() => data.filters.to));
|
let toDate = $state(untrack(() => data.filters.to));
|
||||||
let sortDir = $state(untrack(() => data.filters.dir));
|
let sortDir = $state(untrack(() => data.filters.dir));
|
||||||
|
|
||||||
// Sync with server data after navigation
|
// Sync with server data after navigation
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
senderId = data.filters.senderId;
|
senderId = data.filters.senderId;
|
||||||
receiverId = data.filters.receiverId;
|
receiverId = data.filters.receiverId;
|
||||||
fromDate = data.filters.from;
|
fromDate = data.filters.from;
|
||||||
toDate = data.filters.to;
|
toDate = data.filters.to;
|
||||||
sortDir = data.filters.dir;
|
sortDir = data.filters.dir;
|
||||||
});
|
});
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
const params = new URLSearchParams();
|
const params = new SvelteURLSearchParams();
|
||||||
if (senderId) params.set('senderId', senderId);
|
if (senderId) params.set('senderId', senderId);
|
||||||
if (receiverId) params.set('receiverId', receiverId);
|
if (receiverId) params.set('receiverId', receiverId);
|
||||||
if (fromDate) params.set('from', fromDate);
|
if (fromDate) params.set('from', fromDate);
|
||||||
if (toDate) params.set('to', toDate);
|
if (toDate) params.set('to', toDate);
|
||||||
params.set('dir', sortDir);
|
params.set('dir', sortDir);
|
||||||
goto(`/conversations?${params.toString()}`, { keepFocus: true });
|
goto(`/conversations?${params.toString()}`, { keepFocus: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSort() {
|
function toggleSort() {
|
||||||
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
|
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
|
||||||
applyFilters();
|
applyFilters();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-5xl mx-auto py-10 px-4">
|
<div class="mx-auto max-w-5xl px-4 py-10">
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="mb-8 border-b border-brand-navy/10 pb-4">
|
<div class="mb-8 border-b border-brand-navy/10 pb-4">
|
||||||
<h1 class="text-3xl font-serif font-medium text-brand-navy">{m.conv_heading()}</h1>
|
<h1 class="font-serif text-3xl font-medium text-brand-navy">{m.conv_heading()}</h1>
|
||||||
<p class="text-brand-navy/60 font-sans text-sm mt-2">
|
<p class="mt-2 font-sans text-sm text-brand-navy/60">
|
||||||
{m.conv_subtitle()}
|
{m.conv_subtitle()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FILTER BAR -->
|
<!-- FILTER BAR -->
|
||||||
<div class="bg-white p-8 shadow-sm border border-brand-sand mb-10 relative z-20">
|
<div class="relative z-20 mb-10 border border-brand-sand bg-white p-8 shadow-sm">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-6">
|
<div class="mb-6 grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
<!-- Sender -->
|
<!-- Sender -->
|
||||||
<div
|
<div
|
||||||
class="relative z-30 [&_label]:text-xs [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:mb-2 [&_input]:py-2.5 [&_input]:border-gray-300 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy"
|
class="relative z-30 [&_input]:border-gray-300 [&_input]:py-2.5 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
|
||||||
>
|
>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="senderId"
|
name="senderId"
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
|
|
||||||
<!-- Receiver -->
|
<!-- Receiver -->
|
||||||
<div
|
<div
|
||||||
class="relative z-30 [&_label]:text-xs [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:mb-2 [&_input]:py-2.5 [&_input]:border-gray-300 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy"
|
class="relative z-30 [&_input]:border-gray-300 [&_input]:py-2.5 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
|
||||||
>
|
>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="receiverId"
|
name="receiverId"
|
||||||
@@ -77,12 +78,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 items-end relative z-10">
|
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
|
||||||
<!-- Date From -->
|
<!-- Date From -->
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="dateFrom"
|
for="dateFrom"
|
||||||
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
|
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||||
>{m.conv_label_from()}</label
|
>{m.conv_label_from()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -90,7 +91,7 @@
|
|||||||
type="date"
|
type="date"
|
||||||
bind:value={fromDate}
|
bind:value={fromDate}
|
||||||
onchange={() => applyFilters()}
|
onchange={() => applyFilters()}
|
||||||
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
|
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="dateTo"
|
for="dateTo"
|
||||||
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
|
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||||
>{m.conv_label_to()}</label
|
>{m.conv_label_to()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -106,7 +107,7 @@
|
|||||||
type="date"
|
type="date"
|
||||||
bind:value={toDate}
|
bind:value={toDate}
|
||||||
onchange={() => applyFilters()}
|
onchange={() => applyFilters()}
|
||||||
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
|
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,12 +115,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onclick={toggleSort}
|
onclick={toggleSort}
|
||||||
class="w-full flex items-center justify-center h-[42px] border border-brand-sand text-xs font-bold uppercase tracking-wide text-brand-navy hover:bg-brand-navy hover:text-white transition-colors"
|
class="flex h-[42px] w-full items-center justify-center border border-brand-sand text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
||||||
>
|
>
|
||||||
<span class="mr-2">{m.conv_sort_label()}</span>
|
<span class="mr-2">{m.conv_sort_label()}</span>
|
||||||
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
|
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
|
||||||
<svg
|
<svg
|
||||||
class="w-4 h-4 ml-2 transform {sortDir === 'ASC'
|
class="ml-2 h-4 w-4 transform {sortDir === 'ASC'
|
||||||
? 'rotate-180'
|
? 'rotate-180'
|
||||||
: ''} transition-transform"
|
: ''} transition-transform"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -137,10 +138,10 @@
|
|||||||
<!-- RESULTS LIST SECTION -->
|
<!-- RESULTS LIST SECTION -->
|
||||||
{#if !senderId || !receiverId}
|
{#if !senderId || !receiverId}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand border-dashed rounded-sm text-center"
|
class="flex flex-col items-center justify-center rounded-sm border border-dashed border-brand-sand bg-white py-24 text-center"
|
||||||
>
|
>
|
||||||
<div class="bg-brand-sand/30 p-4 rounded-full mb-4 text-brand-navy">
|
<div class="mb-4 rounded-full bg-brand-sand/30 p-4 text-brand-navy">
|
||||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
@@ -149,44 +150,44 @@
|
|||||||
/></svg
|
/></svg
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-brand-navy font-serif text-lg">{m.conv_empty_heading()}</p>
|
<p class="font-serif text-lg text-brand-navy">{m.conv_empty_heading()}</p>
|
||||||
<p class="text-gray-500 font-sans text-sm mt-1">{m.conv_empty_text()}</p>
|
<p class="mt-1 font-sans text-sm text-gray-500">{m.conv_empty_text()}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if data.documents.length === 0}
|
{:else if data.documents.length === 0}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand rounded-sm text-center shadow-sm"
|
class="flex flex-col items-center justify-center rounded-sm border border-brand-sand bg-white py-24 text-center shadow-sm"
|
||||||
>
|
>
|
||||||
<p class="text-brand-navy font-serif">{m.conv_no_results_heading()}</p>
|
<p class="font-serif text-brand-navy">{m.conv_no_results_heading()}</p>
|
||||||
<p class="text-gray-400 text-sm mt-2">{m.conv_no_results_text()}</p>
|
<p class="mt-2 text-sm text-gray-400">{m.conv_no_results_text()}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- CHAT CONTAINER -->
|
<!-- CHAT CONTAINER -->
|
||||||
<div class="bg-white border border-brand-sand shadow-sm rounded-sm relative overflow-hidden">
|
<div class="relative overflow-hidden rounded-sm border border-brand-sand bg-white shadow-sm">
|
||||||
<!-- Decoration: Central Timeline Line -->
|
<!-- Decoration: Central Timeline Line -->
|
||||||
<div
|
<div
|
||||||
class="absolute left-1/2 top-0 bottom-0 w-px bg-brand-sand/30 transform -translate-x-1/2 hidden md:block"
|
class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-brand-sand/30 md:block"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="p-6 md:p-8">
|
<div class="p-6 md:p-8">
|
||||||
<div class="flex flex-col gap-4 relative z-10">
|
<div class="relative z-10 flex flex-col gap-4">
|
||||||
{#each data.documents as doc}
|
{#each data.documents as doc (doc.id)}
|
||||||
{@const isRight = doc.sender?.id === senderId}
|
{@const isRight = doc.sender?.id === senderId}
|
||||||
|
|
||||||
<!-- Message Row -->
|
<!-- Message Row -->
|
||||||
<div class="flex w-full {isRight ? 'justify-end' : 'justify-start'}">
|
<div class="flex w-full {isRight ? 'justify-end' : 'justify-start'}">
|
||||||
<!-- Bubble Group -->
|
<!-- Bubble Group -->
|
||||||
<div
|
<div
|
||||||
class="flex max-w-[90%] md:max-w-[70%] gap-3 {isRight
|
class="flex max-w-[90%] gap-3 md:max-w-[70%] {isRight
|
||||||
? 'flex-row-reverse'
|
? 'flex-row-reverse'
|
||||||
: 'flex-row'}"
|
: 'flex-row'}"
|
||||||
>
|
>
|
||||||
<!-- AVATAR -->
|
<!-- AVATAR -->
|
||||||
<div class="flex-shrink-0 mt-auto mb-1 hidden sm:block">
|
<div class="mt-auto mb-1 hidden flex-shrink-0 sm:block">
|
||||||
<div
|
<div
|
||||||
class="w-8 h-8 rounded-full flex items-center justify-center font-serif text-xs border shadow-sm
|
class="flex h-8 w-8 items-center justify-center rounded-full border font-serif text-xs shadow-sm
|
||||||
{isRight
|
{isRight
|
||||||
? 'bg-brand-navy text-white border-brand-navy'
|
? 'border-brand-navy bg-brand-navy text-white'
|
||||||
: 'bg-white text-brand-navy border-brand-sand'}"
|
: 'border-brand-sand bg-white text-brand-navy'}"
|
||||||
>
|
>
|
||||||
{#if doc.sender}
|
{#if doc.sender}
|
||||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||||
@@ -199,15 +200,15 @@
|
|||||||
<!-- BUBBLE CARD -->
|
<!-- BUBBLE CARD -->
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}"
|
href="/documents/{doc.id}"
|
||||||
class="group block p-4 rounded shadow-sm transition-all duration-200 transform hover:-translate-y-0.5 hover:shadow-md border
|
class="group block transform rounded border p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md
|
||||||
{isRight
|
{isRight
|
||||||
? 'bg-brand-navy text-white border-brand-navy rounded-br-none'
|
? 'rounded-br-none border-brand-navy bg-brand-navy text-white'
|
||||||
: 'bg-brand-sand/10 text-brand-navy border-brand-sand rounded-bl-none'}"
|
: 'rounded-bl-none border-brand-sand bg-brand-sand/10 text-brand-navy'}"
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex justify-between items-start gap-4 mb-2">
|
<div class="mb-2 flex items-start justify-between gap-4">
|
||||||
<h3
|
<h3
|
||||||
class="font-serif font-medium text-sm leading-snug {isRight
|
class="font-serif text-sm leading-snug font-medium {isRight
|
||||||
? 'text-white'
|
? 'text-white'
|
||||||
: 'text-brand-navy'}"
|
: 'text-brand-navy'}"
|
||||||
>
|
>
|
||||||
@@ -216,7 +217,7 @@
|
|||||||
|
|
||||||
<!-- Status Dot -->
|
<!-- Status Dot -->
|
||||||
<span
|
<span
|
||||||
class="flex-shrink-0 w-1.5 h-1.5 rounded-full mt-1.5
|
class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full
|
||||||
{doc.status === 'UPLOADED'
|
{doc.status === 'UPLOADED'
|
||||||
? 'bg-brand-mint'
|
? 'bg-brand-mint'
|
||||||
: 'bg-yellow-400'}"
|
: 'bg-yellow-400'}"
|
||||||
@@ -227,7 +228,7 @@
|
|||||||
|
|
||||||
<!-- Metadata -->
|
<!-- Metadata -->
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-3 text-[10px] font-sans uppercase tracking-wider opacity-80 {isRight
|
class="flex flex-wrap gap-3 font-sans text-[10px] tracking-wider uppercase opacity-80 {isRight
|
||||||
? 'text-blue-100'
|
? 'text-blue-100'
|
||||||
: 'text-gray-500'}"
|
: 'text-gray-500'}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a href={resolve('/demo/paraglide')}>paraglide</a>
|
<a href={resolve('/demo/paraglide')}>paraglide</a>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { setLocale } from '$lib/paraglide/runtime';
|
import { setLocale } from '$lib/paraglide/runtime';
|
||||||
import { page } from '$app/state';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<h1>{m.nav_documents()}</h1>
|
<h1>{m.nav_documents()}</h1>
|
||||||
<div>
|
<div>
|
||||||
<button onclick={() => setLocale('en')}>en</button>
|
<button onclick={() => setLocale('en')}>en</button>
|
||||||
<button onclick={() => setLocale('es')}>es</button>
|
<button onclick={() => setLocale('es')}>es</button>
|
||||||
<button onclick={() => setLocale('de')}>de</button>
|
<button onclick={() => setLocale('de')}>de</button>
|
||||||
</div><p>
|
</div>
|
||||||
If you use VSCode, install the <a href="https://marketplace.visualstudio.com/items?itemName=inlang.vs-code-extension" target="_blank">Sherlock i18n extension</a> for a better i18n experience.
|
<p>
|
||||||
|
If you use VSCode, install the <a
|
||||||
|
href="https://marketplace.visualstudio.com/items?itemName=inlang.vs-code-extension"
|
||||||
|
target="_blank">Sherlock i18n extension</a
|
||||||
|
> for a better i18n experience.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ import { createApiClient } from '$lib/api.server';
|
|||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export async function load({ params, fetch }) {
|
export async function load({ params, fetch }) {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const result = await api.GET('/api/documents/{id}', { params: { path: { id } } });
|
const result = await api.GET('/api/documents/{id}', { params: { path: { id } } });
|
||||||
|
|
||||||
if (result.response.status === 401) throw redirect(302, '/login');
|
if (result.response.status === 401) throw redirect(302, '/login');
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
const code = (result.error as unknown as { code?: string })?.code;
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
throw error(result.response.status, getErrorMessage(code));
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { document: result.data! };
|
return { document: result.data! };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,178 +1,216 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const doc = $derived(data.document);
|
const doc = $derived(data.document);
|
||||||
|
|
||||||
let fileUrl = $state('');
|
let fileUrl = $state('');
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (doc?.id && doc?.filePath) {
|
if (doc?.id && doc?.filePath) {
|
||||||
loadFile(doc.id);
|
loadFile(doc.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadFile(id: string) {
|
async function loadFile(id: string) {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
error = '';
|
error = '';
|
||||||
fileUrl = '';
|
fileUrl = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/documents/${id}/file`);
|
const response = await fetch(`/api/documents/${id}/file`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401) throw new Error('Nicht eingeloggt');
|
if (response.status === 401) throw new Error('Nicht eingeloggt');
|
||||||
throw new Error('Fehler beim Laden der Datei');
|
throw new Error('Fehler beim Laden der Datei');
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
fileUrl = URL.createObjectURL(blob);
|
fileUrl = URL.createObjectURL(blob);
|
||||||
|
} catch (e) {
|
||||||
} catch (e) {
|
console.error(e);
|
||||||
console.error(e);
|
error = m.doc_file_error_preview();
|
||||||
error = m.doc_file_error_preview();
|
} finally {
|
||||||
} finally {
|
isLoading = false;
|
||||||
isLoading = false;
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-screen flex flex-col bg-white">
|
<div class="flex h-screen flex-col bg-white">
|
||||||
<!-- Top Bar -->
|
<!-- Top Bar -->
|
||||||
<div
|
<div
|
||||||
class="bg-white border-b border-brand-sand px-6 py-4 flex items-center justify-between z-10 shadow-sm"
|
class="z-10 flex items-center justify-between border-b border-brand-sand bg-white px-6 py-4 shadow-sm"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-6 overflow-hidden">
|
<div class="flex items-center gap-6 overflow-hidden">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="group flex-shrink-0 flex items-center gap-2 text-sm font-sans font-medium text-gray-500 hover:text-brand-navy transition-colors"
|
class="group flex flex-shrink-0 items-center gap-2 font-sans text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-8 h-8 rounded-full bg-brand-sand group-hover:bg-brand-mint flex items-center justify-center transition-colors"
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-sand transition-colors group-hover:bg-brand-mint"
|
||||||
>
|
>
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg" alt="" aria-hidden="true" class="w-4 h-4" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span>{m.btn_back()}</span>
|
<span>{m.btn_back()}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="flex items-center gap-4 overflow-hidden border-l border-gray-200 pl-6">
|
<div class="flex items-center gap-4 overflow-hidden border-l border-gray-200 pl-6">
|
||||||
<h1 class="text-xl font-serif text-brand-navy truncate" title={doc.title}>
|
<h1 class="truncate font-serif text-xl text-brand-navy" title={doc.title}>
|
||||||
{doc.title || doc.originalFilename}
|
{doc.title || doc.originalFilename}
|
||||||
</h1>
|
</h1>
|
||||||
<span
|
<span
|
||||||
class="flex-shrink-0 px-3 py-1 rounded-full text-xs font-sans font-bold tracking-wide uppercase
|
class="flex-shrink-0 rounded-full px-3 py-1 font-sans text-xs font-bold tracking-wide uppercase
|
||||||
{doc.status === 'UPLOADED'
|
{doc.status === 'UPLOADED'
|
||||||
? 'bg-brand-mint/30 text-brand-navy border border-brand-mint'
|
? 'border border-brand-mint bg-brand-mint/30 text-brand-navy'
|
||||||
: 'bg-yellow-100 text-yellow-800 border border-yellow-200'}"
|
: 'border border-yellow-200 bg-yellow-100 text-yellow-800'}"
|
||||||
>
|
>
|
||||||
{doc.status}
|
{doc.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 flex-shrink-0 ml-4 font-sans">
|
<div class="ml-4 flex flex-shrink-0 items-center gap-3 font-sans">
|
||||||
{#if data.canWrite}
|
{#if data.canWrite}
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}/edit"
|
href="/documents/{doc.id}/edit"
|
||||||
class="text-brand-navy bg-transparent border border-brand-navy hover:bg-brand-navy hover:text-white px-4 py-2 rounded text-sm font-medium transition flex items-center gap-2"
|
class="flex items-center gap-2 rounded border border-brand-navy bg-transparent px-4 py-2 text-sm font-medium text-brand-navy transition hover:bg-brand-navy hover:text-white"
|
||||||
>
|
>
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg" alt="" aria-hidden="true" class="w-4 h-4" />
|
<img
|
||||||
{m.btn_edit()}
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||||
</a>
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{m.btn_edit()}
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if doc.filePath}
|
{#if doc.filePath}
|
||||||
<a
|
<a
|
||||||
href={fileUrl}
|
href={fileUrl}
|
||||||
download={doc.originalFilename}
|
download={doc.originalFilename}
|
||||||
class="text-brand-navy bg-brand-sand/50 hover:bg-brand-mint border border-transparent p-2 rounded transition"
|
class="rounded border border-transparent bg-brand-sand/50 p-2 text-brand-navy transition hover:bg-brand-mint"
|
||||||
title={m.doc_download_title()}
|
title={m.doc_download_title()}
|
||||||
>
|
>
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content Area -->
|
<!-- Content Area -->
|
||||||
<div class="flex-1 flex overflow-hidden">
|
<div class="flex flex-1 overflow-hidden">
|
||||||
<!-- LEFT SIDEBAR: METADATA -->
|
<!-- LEFT SIDEBAR: METADATA -->
|
||||||
<aside
|
<aside
|
||||||
class="w-96 bg-white border-r border-brand-sand overflow-y-auto p-8 flex-shrink-0 custom-scrollbar"
|
class="custom-scrollbar w-96 flex-shrink-0 overflow-y-auto border-r border-brand-sand bg-white p-8"
|
||||||
>
|
>
|
||||||
<div class="space-y-10">
|
<div class="space-y-10">
|
||||||
<!-- 1. DETAILS GROUP -->
|
<!-- 1. DETAILS GROUP -->
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="text-xs font-sans font-bold text-brand-navy uppercase tracking-widest mb-4 border-b border-brand-sand pb-2"
|
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||||
>
|
>
|
||||||
{m.doc_section_details()}
|
{m.doc_section_details()}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<!-- Date -->
|
<!-- Date -->
|
||||||
<div class="flex items-start group">
|
<div class="group flex items-start">
|
||||||
<span class="text-brand-mint w-8 mt-0.5">
|
<span class="mt-0.5 w-8 text-brand-mint">
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<span class="block font-serif text-lg text-brand-navy">
|
<span class="block font-serif text-lg text-brand-navy">
|
||||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs font-sans text-gray-500">{m.doc_label_document_date()}</span>
|
<span class="font-sans text-xs text-gray-500">{m.doc_label_document_date()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Creation Location -->
|
<!-- Creation Location -->
|
||||||
<div class="flex items-start group">
|
<div class="group flex items-start">
|
||||||
<span class="text-brand-mint w-8 mt-0.5">
|
<span class="mt-0.5 w-8 text-brand-mint">
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<span class="block font-serif text-lg text-brand-navy">
|
<span class="block font-serif text-lg text-brand-navy">
|
||||||
{doc.location ? doc.location : '—'}
|
{doc.location ? doc.location : '—'}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs font-sans text-gray-500">{m.doc_label_creation_location()}</span>
|
<span class="font-sans text-xs text-gray-500"
|
||||||
|
>{m.doc_label_creation_location()}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Physical Archive Location -->
|
<!-- Physical Archive Location -->
|
||||||
{#if doc.documentLocation}
|
{#if doc.documentLocation}
|
||||||
<div class="flex items-start group">
|
<div class="group flex items-start">
|
||||||
<span class="text-brand-mint w-8 mt-0.5">
|
<span class="mt-0.5 w-8 text-brand-mint">
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<span class="block font-serif text-lg text-brand-navy">
|
<span class="block font-serif text-lg text-brand-navy">
|
||||||
{doc.documentLocation}
|
{doc.documentLocation}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs font-sans text-gray-500">{m.doc_label_archive_location_original()}</span>
|
<span class="font-sans text-xs text-gray-500"
|
||||||
|
>{m.doc_label_archive_location_original()}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- TAGS / SCHLAGWORTE -->
|
<!-- TAGS / SCHLAGWORTE -->
|
||||||
{#if doc.tags && doc.tags.length > 0}
|
{#if doc.tags && doc.tags.length > 0}
|
||||||
<div class="flex items-start group">
|
<div class="group flex items-start">
|
||||||
<span class="text-brand-mint w-8 mt-0.5">
|
<span class="mt-0.5 w-8 text-brand-mint">
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex flex-wrap gap-2 mb-1">
|
<div class="mb-1 flex flex-wrap gap-2">
|
||||||
{#each doc.tags as tag}
|
{#each doc.tags as tag (tag.id)}
|
||||||
<a
|
<a
|
||||||
href="/?tag={encodeURIComponent(tag.name)}"
|
href="/?tag={encodeURIComponent(tag.name)}"
|
||||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold uppercase tracking-wide bg-brand-sand/50 text-brand-navy hover:bg-brand-navy hover:text-white transition-colors"
|
class="inline-flex items-center rounded bg-brand-sand/50 px-2 py-0.5 text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
||||||
title={m.doc_tag_filter_title({ name: tag.name })}
|
title={m.doc_tag_filter_title({ name: tag.name })}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs font-sans text-gray-500">{m.form_label_tags()}</span>
|
<span class="font-sans text-xs text-gray-500">{m.form_label_tags()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -182,58 +220,64 @@
|
|||||||
<!-- 2. PERSONEN GROUP -->
|
<!-- 2. PERSONEN GROUP -->
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="text-xs font-sans font-bold text-brand-navy uppercase tracking-widest mb-4 border-b border-brand-sand pb-2"
|
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||||
>
|
>
|
||||||
{m.doc_section_persons()}
|
{m.doc_section_persons()}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">{m.form_label_sender()}</span>
|
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||||
|
>{m.form_label_sender()}</span
|
||||||
|
>
|
||||||
{#if doc.sender}
|
{#if doc.sender}
|
||||||
<a
|
<a
|
||||||
href="/persons/{doc.sender.id}"
|
href="/persons/{doc.sender.id}"
|
||||||
class="block p-3 rounded border border-brand-sand bg-brand-sand/20 hover:border-brand-mint hover:bg-brand-mint/10 transition group"
|
class="group block rounded border border-brand-sand bg-brand-sand/20 p-3 transition hover:border-brand-mint hover:bg-brand-mint/10"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
class="w-8 h-8 rounded-full bg-brand-navy text-white flex items-center justify-center font-serif text-sm"
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-serif text-sm text-white"
|
||||||
>
|
>
|
||||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
class="font-serif text-brand-navy group-hover:underline decoration-brand-mint underline-offset-2"
|
class="font-serif text-brand-navy decoration-brand-mint underline-offset-2 group-hover:underline"
|
||||||
>
|
>
|
||||||
{doc.sender.firstName}
|
{doc.sender.firstName}
|
||||||
{doc.sender.lastName}
|
{doc.sender.lastName}
|
||||||
</p>
|
</p>
|
||||||
{#if doc.sender.alias}
|
{#if doc.sender.alias}
|
||||||
<p class="text-xs font-sans text-gray-500">{doc.sender.alias}</p>
|
<p class="font-sans text-xs text-gray-500">{doc.sender.alias}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-sm font-serif text-gray-400 italic">{m.doc_sender_not_specified()}</span>
|
<span class="font-serif text-sm text-gray-400 italic"
|
||||||
|
>{m.doc_sender_not_specified()}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">{m.form_label_receivers()}</span>
|
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||||
|
>{m.form_label_receivers()}</span
|
||||||
|
>
|
||||||
{#if doc.receivers && doc.receivers.length > 0}
|
{#if doc.receivers && doc.receivers.length > 0}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each doc.receivers as receiver}
|
{#each doc.receivers as receiver (receiver.id)}
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between p-3 rounded border border-brand-sand bg-white hover:border-brand-navy transition group"
|
class="group flex items-center justify-between rounded border border-brand-sand bg-white p-3 transition hover:border-brand-navy"
|
||||||
>
|
>
|
||||||
<a href="/persons/{receiver.id}" class="flex items-center gap-3 flex-1 min-w-0">
|
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<div
|
<div
|
||||||
class="w-6 h-6 rounded-full bg-gray-100 text-gray-500 flex items-center justify-center text-xs font-serif"
|
class="flex h-6 w-6 items-center justify-center rounded-full bg-gray-100 font-serif text-xs text-gray-500"
|
||||||
>
|
>
|
||||||
{receiver.firstName[0]}{receiver.lastName[0]}
|
{receiver.firstName[0]}{receiver.lastName[0]}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="font-serif text-sm text-brand-navy group-hover:text-brand-navy truncate"
|
class="truncate font-serif text-sm text-brand-navy group-hover:text-brand-navy"
|
||||||
>
|
>
|
||||||
{receiver.firstName}
|
{receiver.firstName}
|
||||||
{receiver.lastName}
|
{receiver.lastName}
|
||||||
@@ -243,17 +287,22 @@
|
|||||||
{#if doc.sender}
|
{#if doc.sender}
|
||||||
<a
|
<a
|
||||||
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
|
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
|
||||||
class="text-gray-300 hover:text-brand-mint transition"
|
class="text-gray-300 transition hover:text-brand-mint"
|
||||||
title={m.doc_conversation_title()}
|
title={m.doc_conversation_title()}
|
||||||
>
|
>
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-sm font-serif text-gray-400 italic">{m.doc_no_receivers()}</span>
|
<span class="font-serif text-sm text-gray-400 italic">{m.doc_no_receivers()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,7 +311,7 @@
|
|||||||
{#if doc.summary || doc.transcription}
|
{#if doc.summary || doc.transcription}
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="text-xs font-sans font-bold text-brand-navy uppercase tracking-widest mb-4 border-b border-brand-sand pb-2"
|
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||||
>
|
>
|
||||||
{m.doc_section_content()}
|
{m.doc_section_content()}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -270,9 +319,11 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{#if doc.summary}
|
{#if doc.summary}
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">{m.doc_label_summary()}</span>
|
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||||
|
>{m.doc_label_summary()}</span
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap"
|
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
|
||||||
>
|
>
|
||||||
{doc.summary}
|
{doc.summary}
|
||||||
</div>
|
</div>
|
||||||
@@ -281,9 +332,11 @@
|
|||||||
|
|
||||||
{#if doc.transcription}
|
{#if doc.transcription}
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">{m.form_label_transcription()}</span>
|
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||||
|
>{m.form_label_transcription()}</span
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap"
|
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
|
||||||
>
|
>
|
||||||
{doc.transcription}
|
{doc.transcription}
|
||||||
</div>
|
</div>
|
||||||
@@ -294,19 +347,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="pt-4 border-t border-brand-sand text-[10px] font-sans text-gray-400">
|
<div class="border-t border-brand-sand pt-4 font-sans text-[10px] text-gray-400">
|
||||||
<p class="truncate">ID: {doc.id}</p>
|
<p class="truncate">ID: {doc.id}</p>
|
||||||
<p class="truncate mt-1">{doc.originalFilename}</p>
|
<p class="mt-1 truncate">{doc.originalFilename}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- RIGHT: PREVIEW AREA -->
|
<!-- RIGHT: PREVIEW AREA -->
|
||||||
<main class="flex-1 bg-[#2A2A2A] relative flex flex-col items-center justify-center">
|
<main class="relative flex flex-1 flex-col items-center justify-center bg-[#2A2A2A]">
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<div class="text-brand-mint flex flex-col items-center">
|
<div class="flex flex-col items-center text-brand-mint">
|
||||||
<svg
|
<svg
|
||||||
class="animate-spin h-8 w-8 mb-4"
|
class="mb-4 h-8 w-8 animate-spin"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -322,13 +375,13 @@
|
|||||||
<span class="font-sans text-sm tracking-wide">{m.doc_loading()}</span>
|
<span class="font-sans text-sm tracking-wide">{m.doc_loading()}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="text-gray-400 text-center px-4">
|
<div class="px-4 text-center text-gray-400">
|
||||||
<p class="font-serif mb-2">{error}</p>
|
<p class="mb-2 font-serif">{error}</p>
|
||||||
{#if doc.filePath}
|
{#if doc.filePath}
|
||||||
<a
|
<a
|
||||||
href={`/api/documents/${doc.id}/file`}
|
href={`/api/documents/${doc.id}/file`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="underline hover:text-white text-sm"
|
class="text-sm underline hover:text-white"
|
||||||
>
|
>
|
||||||
{m.doc_download_link()}
|
{m.doc_download_link()}
|
||||||
</a>
|
</a>
|
||||||
@@ -336,8 +389,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if !doc.filePath}
|
{:else if !doc.filePath}
|
||||||
<div class="flex flex-col items-center text-gray-400">
|
<div class="flex flex-col items-center text-gray-400">
|
||||||
<div class="bg-white/5 p-8 rounded-full mb-6">
|
<div class="mb-6 rounded-full bg-white/5 p-8">
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg" alt="" aria-hidden="true" class="w-12 h-12 opacity-50 invert" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-12 w-12 opacity-50 invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,14 +403,14 @@
|
|||||||
<iframe
|
<iframe
|
||||||
src={fileUrl}
|
src={fileUrl}
|
||||||
title={m.doc_preview_iframe_title()}
|
title={m.doc_preview_iframe_title()}
|
||||||
class="w-full h-full border-none bg-white"
|
class="h-full w-full border-none bg-white"
|
||||||
></iframe>
|
></iframe>
|
||||||
{:else if fileUrl}
|
{:else if fileUrl}
|
||||||
<div class="w-full h-full flex items-center justify-center overflow-auto p-8">
|
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
||||||
<img
|
<img
|
||||||
src={fileUrl}
|
src={fileUrl}
|
||||||
alt={m.doc_image_alt()}
|
alt={m.doc_image_alt()}
|
||||||
class="max-w-full max-h-full object-contain shadow-2xl"
|
class="max-h-full max-w-full object-contain shadow-2xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -3,49 +3,60 @@ import { env } from '$env/dynamic/private';
|
|||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { parseBackendError, getErrorMessage } from '$lib/errors';
|
import { parseBackendError, getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export async function load({ params, fetch, locals }: { params: { id: string }; fetch: typeof globalThis.fetch; locals: App.Locals }) {
|
export async function load({
|
||||||
const canWrite = locals.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('WRITE_ALL')) ?? false;
|
params,
|
||||||
if (!canWrite) throw error(403, 'Forbidden');
|
fetch,
|
||||||
|
locals
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
fetch: typeof globalThis.fetch;
|
||||||
|
locals: App.Locals;
|
||||||
|
}) {
|
||||||
|
const canWrite =
|
||||||
|
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||||
|
g.permissions.includes('WRITE_ALL')
|
||||||
|
) ?? false;
|
||||||
|
if (!canWrite) throw error(403, 'Forbidden');
|
||||||
|
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const [docResult, personsResult] = await Promise.all([
|
const [docResult, personsResult] = await Promise.all([
|
||||||
api.GET('/api/documents/{id}', { params: { path: { id } } }),
|
api.GET('/api/documents/{id}', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons')
|
api.GET('/api/persons')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!docResult.response.ok) {
|
if (!docResult.response.ok) {
|
||||||
const code = (docResult.error as unknown as { code?: string })?.code;
|
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||||
throw error(docResult.response.status, getErrorMessage(code));
|
throw error(docResult.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
if (!personsResult.response.ok) {
|
if (!personsResult.response.ok) {
|
||||||
throw error(personsResult.response.status, getErrorMessage('INTERNAL_ERROR'));
|
throw error(personsResult.response.status, getErrorMessage('INTERNAL_ERROR'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
document: docResult.data!,
|
document: docResult.data!,
|
||||||
persons: personsResult.data
|
persons: personsResult.data
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ request, params, fetch }) => {
|
default: async ({ request, params, fetch }) => {
|
||||||
// Raw fetch is used here because FormData multipart bodies are passed through
|
// Raw fetch is used here because FormData multipart bodies are passed through
|
||||||
// directly from the browser without transformation.
|
// directly from the browser without transformation.
|
||||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
|
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const backendError = await parseBackendError(res);
|
const backendError = await parseBackendError(res);
|
||||||
return fail(res.status, { error: getErrorMessage(backendError?.code) });
|
return fail(res.status, { error: getErrorMessage(backendError?.code) });
|
||||||
}
|
}
|
||||||
|
|
||||||
throw redirect(303, `/documents/${params.id}`);
|
throw redirect(303, `/documents/${params.id}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,228 +1,263 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import TagInput from '$lib/components/TagInput.svelte';
|
import TagInput from '$lib/components/TagInput.svelte';
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { isoToGerman, germanToIso } from '$lib/utils';
|
import { isoToGerman, germanToIso } from '$lib/utils';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
let { document: doc } = untrack(() => data);
|
let { document: doc } = untrack(() => data);
|
||||||
let tags = $state(doc.tags ? doc.tags.map((t: { name: string }) => t.name) : []);
|
let tags = $state(doc.tags ? doc.tags.map((t: { name: string }) => t.name) : []);
|
||||||
let senderId = $state(doc.sender?.id ?? '');
|
let senderId = $state(doc.sender?.id ?? '');
|
||||||
let selectedReceivers = $state(doc.receivers ?? []);
|
let selectedReceivers = $state(doc.receivers ?? []);
|
||||||
|
|
||||||
let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
|
let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
|
||||||
let dateIso = $state(doc.documentDate ?? '');
|
let dateIso = $state(doc.documentDate ?? '');
|
||||||
let dateDirty = $state(false);
|
let dateDirty = $state(false);
|
||||||
|
|
||||||
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||||
|
|
||||||
function handleDateInput(e: Event) {
|
function handleDateInput(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const digits = input.value.replace(/\D/g, '').slice(0, 8);
|
const digits = input.value.replace(/\D/g, '').slice(0, 8);
|
||||||
let formatted: string;
|
let formatted: string;
|
||||||
if (digits.length <= 2) {
|
if (digits.length <= 2) {
|
||||||
formatted = digits;
|
formatted = digits;
|
||||||
} else if (digits.length <= 4) {
|
} else if (digits.length <= 4) {
|
||||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||||
} else {
|
} else {
|
||||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||||
}
|
}
|
||||||
input.value = formatted;
|
input.value = formatted;
|
||||||
dateDisplay = formatted;
|
dateDisplay = formatted;
|
||||||
dateIso = germanToIso(formatted);
|
dateIso = germanToIso(formatted);
|
||||||
dateDirty = true;
|
dateDirty = true;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-4xl mx-auto py-8 px-4">
|
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||||
|
<!-- Heading -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<a
|
||||||
|
href="/documents/{doc.id}"
|
||||||
|
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||||
|
/>
|
||||||
|
{m.btn_back_to_document()}
|
||||||
|
</a>
|
||||||
|
<h1 class="font-serif text-3xl text-brand-navy">
|
||||||
|
{m.doc_edit_heading()} —
|
||||||
|
<span class="text-brand-navy/70">{doc.title || doc.originalFilename}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Heading -->
|
{#if form?.error}
|
||||||
<div class="mb-6">
|
<div class="mb-6 rounded border border-red-200 bg-red-50 p-4 text-red-700">{form.error}</div>
|
||||||
<a href="/documents/{doc.id}" class="inline-flex items-center text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy transition-colors group mb-4">
|
{/if}
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg" alt="" aria-hidden="true" class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" />
|
|
||||||
{m.btn_back_to_document()}
|
|
||||||
</a>
|
|
||||||
<h1 class="text-3xl font-serif text-brand-navy">
|
|
||||||
{m.doc_edit_heading()} — <span class="text-brand-navy/70">{doc.title || doc.originalFilename}</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if form?.error}
|
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
||||||
<div class="bg-red-50 text-red-700 border border-red-200 p-4 rounded mb-6">{form.error}</div>
|
<!-- ── Section 1: Wer & Wann ── -->
|
||||||
{/if}
|
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||||
|
{m.doc_section_who_when()}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
|
<!-- Datum -->
|
||||||
<!-- ── Section 1: Wer & Wann ── -->
|
<div>
|
||||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
<label for="documentDate" class="mb-1 block text-sm font-medium text-gray-700"
|
||||||
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">{m.doc_section_who_when()}</h2>
|
>{m.form_label_date()}</label
|
||||||
|
>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
<input
|
||||||
|
id="documentDate"
|
||||||
<!-- Datum -->
|
type="text"
|
||||||
<div>
|
inputmode="numeric"
|
||||||
<label for="documentDate" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_date()}</label>
|
value={dateDisplay}
|
||||||
<input
|
oninput={handleDateInput}
|
||||||
id="documentDate"
|
placeholder={m.form_placeholder_date()}
|
||||||
type="text"
|
maxlength="10"
|
||||||
inputmode="numeric"
|
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm
|
||||||
value={dateDisplay}
|
|
||||||
oninput={handleDateInput}
|
|
||||||
placeholder={m.form_placeholder_date()}
|
|
||||||
maxlength="10"
|
|
||||||
class="block w-full rounded border-gray-300 shadow-sm p-2 border text-sm
|
|
||||||
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-brand-navy focus:ring-brand-navy'}"
|
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-brand-navy focus:ring-brand-navy'}"
|
||||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="documentDate" value={dateIso} />
|
<input type="hidden" name="documentDate" value={dateIso} />
|
||||||
{#if dateInvalid}
|
{#if dateInvalid}
|
||||||
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ort -->
|
<!-- Ort -->
|
||||||
<div>
|
<div>
|
||||||
<label for="location" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_location()}</label>
|
<label for="location" class="mb-1 block text-sm font-medium text-gray-700"
|
||||||
<input
|
>{m.form_label_location()}</label
|
||||||
id="location"
|
>
|
||||||
type="text"
|
<input
|
||||||
name="location"
|
id="location"
|
||||||
value={doc.location || ''}
|
type="text"
|
||||||
placeholder={m.form_placeholder_location()}
|
name="location"
|
||||||
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm"
|
value={doc.location || ''}
|
||||||
/>
|
placeholder={m.form_placeholder_location()}
|
||||||
</div>
|
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Absender -->
|
<!-- Absender -->
|
||||||
<div>
|
<div>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="senderId"
|
name="senderId"
|
||||||
label={m.form_label_sender()}
|
label={m.form_label_sender()}
|
||||||
bind:value={senderId}
|
bind:value={senderId}
|
||||||
initialName={doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : ''}
|
initialName={doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empfänger -->
|
<!-- Empfänger -->
|
||||||
<div>
|
<div>
|
||||||
<p class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_receivers()}</p>
|
<p class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_receivers()}</p>
|
||||||
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
|
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
<!-- ── Section 2: Beschreibung ── -->
|
||||||
</div>
|
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||||
|
{m.doc_section_description()}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<!-- ── Section 2: Beschreibung ── -->
|
<div class="space-y-5">
|
||||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
<!-- Titel -->
|
||||||
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">{m.doc_section_description()}</h2>
|
<div>
|
||||||
|
<label for="title" class="mb-1 block text-sm font-medium text-gray-700"
|
||||||
|
>{m.form_label_title()} *</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
value={doc.title || ''}
|
||||||
|
required
|
||||||
|
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<!-- Aufbewahrungsort -->
|
||||||
|
<div>
|
||||||
|
<label for="documentLocation" class="mb-1 block text-sm font-medium text-gray-700"
|
||||||
|
>{m.form_label_archive_location()}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="documentLocation"
|
||||||
|
type="text"
|
||||||
|
name="documentLocation"
|
||||||
|
value={doc.documentLocation || ''}
|
||||||
|
placeholder={m.form_placeholder_archive_location()}
|
||||||
|
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">{m.form_helper_archive_location()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Titel -->
|
<!-- Schlagworte -->
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_title()} *</label>
|
<p class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_tags()}</p>
|
||||||
<input
|
<TagInput bind:tags={tags} />
|
||||||
id="title"
|
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||||
type="text"
|
</div>
|
||||||
name="title"
|
|
||||||
value={doc.title || ''}
|
|
||||||
required
|
|
||||||
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Aufbewahrungsort -->
|
<!-- Inhalt -->
|
||||||
<div>
|
<div>
|
||||||
<label for="documentLocation" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_archive_location()}</label>
|
<label for="summary" class="mb-1 block text-sm font-medium text-gray-700"
|
||||||
<input
|
>{m.form_label_content()}</label
|
||||||
id="documentLocation"
|
>
|
||||||
type="text"
|
<textarea
|
||||||
name="documentLocation"
|
id="summary"
|
||||||
value={doc.documentLocation || ''}
|
name="summary"
|
||||||
placeholder={m.form_placeholder_archive_location()}
|
rows="5"
|
||||||
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm"
|
placeholder={m.form_placeholder_content()}
|
||||||
/>
|
class="block w-full rounded border border-gray-300 p-2 font-serif text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||||
<p class="mt-1 text-xs text-gray-400">{m.form_helper_archive_location()}</p>
|
>{doc.summary || ''}</textarea
|
||||||
</div>
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Schlagworte -->
|
<!-- ── Section 3: Transkription ── -->
|
||||||
<div>
|
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||||
<p class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_tags()}</p>
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||||
<TagInput bind:tags />
|
{m.form_label_transcription()}
|
||||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
</h2>
|
||||||
</div>
|
<textarea
|
||||||
|
id="transcription"
|
||||||
|
name="transcription"
|
||||||
|
rows="12"
|
||||||
|
placeholder={m.form_placeholder_transcription()}
|
||||||
|
class="block w-full rounded border border-gray-300 p-2 font-serif text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||||
|
>{doc.transcription || ''}</textarea
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Inhalt -->
|
<!-- ── Section 4: Datei ── -->
|
||||||
<div>
|
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||||
<label for="summary" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_content()}</label>
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||||
<textarea
|
{m.doc_section_file()}
|
||||||
id="summary"
|
</h2>
|
||||||
name="summary"
|
|
||||||
rows="5"
|
|
||||||
placeholder={m.form_placeholder_content()}
|
|
||||||
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm font-serif"
|
|
||||||
>{doc.summary || ''}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
<div
|
||||||
</div>
|
class="mb-4 flex items-center gap-3 rounded bg-brand-sand/20 px-3 py-2 text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
>{m.doc_current_file_label()}
|
||||||
|
<strong class="font-medium text-brand-navy">{doc.originalFilename}</strong></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Section 3: Transkription ── -->
|
<label for="file-upload" class="mb-1 block text-sm font-medium text-gray-700">
|
||||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
{m.doc_file_replace_label()}
|
||||||
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">{m.form_label_transcription()}</h2>
|
<span class="font-normal text-gray-400">({m.doc_file_replace_note()})</span>
|
||||||
<textarea
|
</label>
|
||||||
id="transcription"
|
<input
|
||||||
name="transcription"
|
id="file-upload"
|
||||||
rows="12"
|
type="file"
|
||||||
placeholder={m.form_placeholder_transcription()}
|
name="file"
|
||||||
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm font-serif"
|
class="block w-full cursor-pointer text-sm
|
||||||
>{doc.transcription || ''}</textarea>
|
text-gray-500 file:mr-4 file:rounded
|
||||||
</div>
|
file:border-0 file:bg-brand-sand/40
|
||||||
|
file:px-4 file:py-2
|
||||||
<!-- ── Section 4: Datei ── -->
|
|
||||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
|
||||||
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">{m.doc_section_file()}</h2>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3 mb-4 text-sm text-gray-600 bg-brand-sand/20 rounded px-3 py-2">
|
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg" alt="" aria-hidden="true" class="w-4 h-4 flex-shrink-0" />
|
|
||||||
<span>{m.doc_current_file_label()} <strong class="text-brand-navy font-medium">{doc.originalFilename}</strong></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label for="file-upload" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{m.doc_file_replace_label()} <span class="font-normal text-gray-400">({m.doc_file_replace_note()})</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="file-upload"
|
|
||||||
type="file"
|
|
||||||
name="file"
|
|
||||||
class="block w-full text-sm text-gray-500
|
|
||||||
file:mr-4 file:py-2 file:px-4
|
|
||||||
file:rounded file:border-0
|
|
||||||
file:text-sm file:font-semibold
|
file:text-sm file:font-semibold
|
||||||
file:bg-brand-sand/40 file:text-brand-navy
|
file:text-brand-navy hover:file:bg-brand-sand/60"
|
||||||
hover:file:bg-brand-sand/60 cursor-pointer"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Sticky Save Bar ── -->
|
<!-- ── Sticky Save Bar ── -->
|
||||||
<div class="sticky bottom-0 z-10 bg-white border-t border-brand-sand shadow-[0_-2px_8px_rgba(0,0,0,0.06)] -mx-4 px-6 py-4 flex items-center justify-between">
|
<div
|
||||||
<a
|
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-brand-sand bg-white px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
|
||||||
href="/documents/{doc.id}"
|
>
|
||||||
class="text-sm text-gray-500 hover:text-brand-navy transition-colors font-medium"
|
<a
|
||||||
>
|
href="/documents/{doc.id}"
|
||||||
{m.btn_cancel()}
|
class="text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
|
||||||
</a>
|
>
|
||||||
<button
|
{m.btn_cancel()}
|
||||||
type="submit"
|
</a>
|
||||||
class="px-6 py-2 bg-brand-navy text-white text-sm font-bold uppercase tracking-widest rounded hover:bg-brand-navy/80 transition-colors"
|
<button
|
||||||
>
|
type="submit"
|
||||||
{m.btn_save()}
|
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/80"
|
||||||
</button>
|
>
|
||||||
</div>
|
{m.btn_save()}
|
||||||
|
</button>
|
||||||
</form>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,8 +3,17 @@ import { env } from '$env/dynamic/private';
|
|||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { parseBackendError, getErrorMessage } from '$lib/errors';
|
import { parseBackendError, getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export async function load({ fetch, locals }: { fetch: typeof globalThis.fetch; locals: App.Locals }) {
|
export async function load({
|
||||||
const canWrite = locals.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('WRITE_ALL')) ?? false;
|
fetch,
|
||||||
|
locals
|
||||||
|
}: {
|
||||||
|
fetch: typeof globalThis.fetch;
|
||||||
|
locals: App.Locals;
|
||||||
|
}) {
|
||||||
|
const canWrite =
|
||||||
|
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||||
|
g.permissions.includes('WRITE_ALL')
|
||||||
|
) ?? false;
|
||||||
if (!canWrite) throw error(403, 'Forbidden');
|
if (!canWrite) throw error(403, 'Forbidden');
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ function handleDateInput(e: Event) {
|
|||||||
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
||||||
<!-- ── Section 1: Wer & Wann ── -->
|
<!-- ── Section 1: Wer & Wann ── -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">{m.doc_section_who_when()}</h2>
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||||
|
{m.doc_section_who_when()}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
<!-- Datum -->
|
<!-- Datum -->
|
||||||
@@ -104,7 +106,9 @@ function handleDateInput(e: Event) {
|
|||||||
|
|
||||||
<!-- Ort -->
|
<!-- Ort -->
|
||||||
<div>
|
<div>
|
||||||
<label for="location" class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_location()}</label>
|
<label for="location" class="mb-1 block text-sm font-medium text-gray-700"
|
||||||
|
>{m.form_label_location()}</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="location"
|
id="location"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -129,12 +133,16 @@ function handleDateInput(e: Event) {
|
|||||||
|
|
||||||
<!-- ── Section 2: Beschreibung ── -->
|
<!-- ── Section 2: Beschreibung ── -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">{m.doc_section_description()}</h2>
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||||
|
{m.doc_section_description()}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<!-- Titel -->
|
<!-- Titel -->
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_title()} *</label>
|
<label for="title" class="mb-1 block text-sm font-medium text-gray-700"
|
||||||
|
>{m.form_label_title()} *</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="title"
|
id="title"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -168,7 +176,9 @@ function handleDateInput(e: Event) {
|
|||||||
|
|
||||||
<!-- Inhalt -->
|
<!-- Inhalt -->
|
||||||
<div>
|
<div>
|
||||||
<label for="summary" class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_content()}</label>
|
<label for="summary" class="mb-1 block text-sm font-medium text-gray-700"
|
||||||
|
>{m.form_label_content()}</label
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
id="summary"
|
id="summary"
|
||||||
name="summary"
|
name="summary"
|
||||||
@@ -182,7 +192,9 @@ function handleDateInput(e: Event) {
|
|||||||
|
|
||||||
<!-- ── Section 3: Transkription ── -->
|
<!-- ── Section 3: Transkription ── -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">{m.form_label_transcription()}</h2>
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||||
|
{m.form_label_transcription()}
|
||||||
|
</h2>
|
||||||
<textarea
|
<textarea
|
||||||
id="transcription"
|
id="transcription"
|
||||||
name="transcription"
|
name="transcription"
|
||||||
@@ -194,10 +206,13 @@ function handleDateInput(e: Event) {
|
|||||||
|
|
||||||
<!-- ── Section 4: Datei ── -->
|
<!-- ── Section 4: Datei ── -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">{m.doc_section_file()}</h2>
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||||
|
{m.doc_section_file()}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<label for="file-upload" class="mb-1 block text-sm font-medium text-gray-700">
|
<label for="file-upload" class="mb-1 block text-sm font-medium text-gray-700">
|
||||||
{m.doc_file_upload_label()} <span class="font-normal text-gray-400">({m.doc_file_upload_note()})</span>
|
{m.doc_file_upload_label()}
|
||||||
|
<span class="font-normal text-gray-400">({m.doc_file_upload_note()})</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="file-upload"
|
id="file-upload"
|
||||||
|
|||||||
@@ -1,39 +1,44 @@
|
|||||||
/* 1. Import Tailwind (replaces @tailwind base/components/utilities) */
|
/* 1. Import Tailwind (replaces @tailwind base/components/utilities) */
|
||||||
/* Fonts: Montserrat = Gotham substitute | Tinos = Times substitute (De Gruyter Brill CI) */
|
/* Fonts: Montserrat = Gotham substitute | Tinos = Times substitute (De Gruyter Brill CI) */
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400;1,700&family=Montserrat:wght@400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400;1,700&family=Montserrat:wght@400;500;600;700&display=swap');
|
||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
|
||||||
/* 2. Define Custom Theme Variables — De Gruyter Brill CI */
|
/* 2. Define Custom Theme Variables — De Gruyter Brill CI */
|
||||||
@theme {
|
@theme {
|
||||||
/* COLORS — exact De Gruyter Brill brand palette */
|
/* COLORS — exact De Gruyter Brill brand palette */
|
||||||
--color-brand-navy: #012851; /* Prussian Blue */
|
--color-brand-navy: #012851; /* Prussian Blue */
|
||||||
--color-brand-mint: #A1DCD8; /* Aqua Island */
|
--color-brand-mint: #a1dcd8; /* Aqua Island */
|
||||||
--color-brand-purple: #B4B9FF; /* Melrose */
|
--color-brand-purple: #b4b9ff; /* Melrose */
|
||||||
--color-brand-sand: #F0EFE9; /* Neutral paper tone */
|
--color-brand-sand: #f0efe9; /* Neutral paper tone */
|
||||||
--color-brand-white: #ffffff;
|
--color-brand-white: #ffffff;
|
||||||
--color-brand-dark: #0D0D0D;
|
--color-brand-dark: #0d0d0d;
|
||||||
|
|
||||||
/* FONTS */
|
/* FONTS */
|
||||||
--font-sans: "Montserrat", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: 'Montserrat', ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-serif: "Tinos", "Times New Roman", Georgia, serif;
|
--font-serif: 'Tinos', 'Times New Roman', Georgia, serif;
|
||||||
|
|
||||||
--text-huge: 4rem;
|
--text-huge: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 3. Base Styles */
|
/* 3. Base Styles */
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
color: var(--color-brand-navy);
|
color: var(--color-brand-navy);
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1,
|
||||||
font-family: var(--font-sans);
|
h2,
|
||||||
font-weight: 600;
|
h3,
|
||||||
}
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,47 +3,47 @@ import { env } from '$env/dynamic/private';
|
|||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
login: async ({ request, cookies, fetch }) => {
|
login: async ({ request, cookies, fetch }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const username = data.get('username') as string;
|
const username = data.get('username') as string;
|
||||||
const password = data.get('password') as string;
|
const password = data.get('password') as string;
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
return fail(400, { error: 'Bitte Benutzername und Passwort eingeben.' });
|
return fail(400, { error: 'Bitte Benutzername und Passwort eingeben.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = btoa(`${username}:${password}`);
|
const credentials = btoa(`${username}:${password}`);
|
||||||
const authHeader = `Basic ${credentials}`;
|
const authHeader = `Basic ${credentials}`;
|
||||||
|
|
||||||
// Raw fetch is intentional here: we need to pass an explicit Authorization
|
// Raw fetch is intentional here: we need to pass an explicit Authorization
|
||||||
// header built from the form data, not the cookie-based auth used elsewhere.
|
// header built from the form data, not the cookie-based auth used elsewhere.
|
||||||
try {
|
try {
|
||||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const response = await fetch(`${baseUrl}/api/users/me`, {
|
const response = await fetch(`${baseUrl}/api/users/me`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { Authorization: authHeader }
|
headers: { Authorization: authHeader }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
return fail(401, { error: getErrorMessage('UNAUTHORIZED') });
|
return fail(401, { error: getErrorMessage('UNAUTHORIZED') });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
||||||
}
|
}
|
||||||
|
|
||||||
cookies.set('auth_token', authHeader, {
|
cookies.set('auth_token', authHeader, {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
secure: false, // set to true when HTTPS is available
|
secure: false, // set to true when HTTPS is available
|
||||||
maxAge: 60 * 60 * 24
|
maxAge: 60 * 60 * 24
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect(303, '/');
|
return redirect(303, '/');
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
|
|||||||
@@ -1,75 +1,101 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||||
|
|
||||||
let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
||||||
|
|
||||||
const locales = ['DE', 'EN', 'ES'] as const;
|
const locales = ['DE', 'EN', 'ES'] as const;
|
||||||
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
|
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
|
||||||
const activeLocale = $derived(getLocale().toUpperCase());
|
const activeLocale = $derived(getLocale().toUpperCase());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative min-h-screen bg-white flex flex-col">
|
<div class="relative flex min-h-screen flex-col bg-white">
|
||||||
<!-- DGB purple accent strip -->
|
<!-- DGB purple accent strip -->
|
||||||
<div class="h-1 bg-brand-purple"></div>
|
<div class="h-1 bg-brand-purple"></div>
|
||||||
|
|
||||||
<!-- Language switcher -->
|
<!-- Language switcher -->
|
||||||
<div class="absolute top-4 right-4 flex items-center gap-1">
|
<div class="absolute top-4 right-4 flex items-center gap-1">
|
||||||
{#each locales as locale (locale)}
|
{#each locales as locale (locale)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => setLocale(localeMap[locale])}
|
onclick={() => setLocale(localeMap[locale])}
|
||||||
class="text-xs font-sans tracking-widest px-1.5 py-1 transition-colors
|
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
|
||||||
{activeLocale === locale
|
{activeLocale === locale
|
||||||
? 'font-bold text-brand-navy'
|
? 'font-bold text-brand-navy'
|
||||||
: 'font-normal text-gray-400 hover:text-brand-navy'}"
|
: 'font-normal text-gray-400 hover:text-brand-navy'}"
|
||||||
>
|
>
|
||||||
{locale}
|
{locale}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 flex items-center justify-center px-4">
|
<div class="flex flex-1 items-center justify-center px-4">
|
||||||
<div class="w-full max-w-sm">
|
<div class="w-full max-w-sm">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="mb-10 text-center">
|
<div class="mb-10 text-center">
|
||||||
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
||||||
<span class="font-sans font-bold text-2xl tracking-widest text-brand-navy uppercase">Familienarchiv</span>
|
<span class="font-sans text-2xl font-bold tracking-widest text-brand-navy uppercase"
|
||||||
</a>
|
>Familienarchiv</span
|
||||||
</div>
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Card -->
|
<!-- Card -->
|
||||||
<div class="bg-white border border-brand-sand rounded-sm shadow-sm p-8">
|
<div class="rounded-sm border border-brand-sand bg-white p-8 shadow-sm">
|
||||||
<h1 class="font-sans text-sm font-bold uppercase tracking-widest text-brand-navy mb-6">{m.login_heading()}</h1>
|
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-brand-navy uppercase">
|
||||||
|
{m.login_heading()}
|
||||||
|
</h1>
|
||||||
|
|
||||||
<form method="POST" action="?/login" class="space-y-5">
|
<form method="POST" action="?/login" class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label for="username" class="block text-xs font-bold font-sans uppercase tracking-widest text-gray-500 mb-1.5">{m.login_label_username()}</label>
|
<label
|
||||||
<input type="text" name="username" id="username" required autocomplete="username"
|
for="username"
|
||||||
class="block w-full border border-gray-300 py-2.5 px-3 text-sm font-serif text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none" />
|
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||||
</div>
|
>{m.login_label_username()}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
id="username"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="password" class="block text-xs font-bold font-sans uppercase tracking-widest text-gray-500 mb-1.5">{m.login_label_password()}</label>
|
<label
|
||||||
<input type="password" name="password" id="password" required autocomplete="current-password"
|
for="password"
|
||||||
class="block w-full border border-gray-300 py-2.5 px-3 text-sm font-serif text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none" />
|
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||||
</div>
|
>{m.login_label_password()}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
id="password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="text-red-600 text-xs font-sans font-medium text-center">{form.error}</div>
|
<div class="text-center font-sans text-xs font-medium text-red-600">{form.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button type="submit"
|
<button
|
||||||
class="w-full bg-brand-navy text-white py-2.5 text-xs font-bold uppercase tracking-widest font-sans hover:bg-brand-navy/90 transition-colors mt-2">
|
type="submit"
|
||||||
{m.login_btn_submit()}
|
class="mt-2 w-full bg-brand-navy py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
||||||
</button>
|
>
|
||||||
</form>
|
{m.login_btn_submit()}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="py-4 text-center">
|
<div class="py-4 text-center">
|
||||||
<p class="text-xs font-sans text-gray-300 uppercase tracking-widest">Familienarchiv</p>
|
<p class="font-sans text-xs tracking-widest text-gray-300 uppercase">Familienarchiv</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { redirect } from '@sveltejs/kit';
|
|||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ cookies }) => {
|
default: async ({ cookies }) => {
|
||||||
// Das Auth-Cookie löschen
|
// Das Auth-Cookie löschen
|
||||||
cookies.delete('auth_token', { path: '/' });
|
cookies.delete('auth_token', { path: '/' });
|
||||||
|
|
||||||
// Zur Login-Seite werfen
|
// Zur Login-Seite werfen
|
||||||
throw redirect(302, '/login');
|
throw redirect(302, '/login');
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ describe('Home page – search bar', () => {
|
|||||||
|
|
||||||
it('pre-fills the search input from filters.q', async () => {
|
it('pre-fills the search input from filters.q', async () => {
|
||||||
render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } });
|
render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } });
|
||||||
await expect.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')).toHaveValue('Urlaub');
|
await expect
|
||||||
|
.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...'))
|
||||||
|
.toHaveValue('Urlaub');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,9 +170,7 @@ describe('Home page – error state', () => {
|
|||||||
it('shows the error message when data.error is set', async () => {
|
it('shows the error message when data.error is set', async () => {
|
||||||
const data = { ...emptyData, error: 'Daten konnten nicht geladen werden.' };
|
const data = { ...emptyData, error: 'Daten konnten nicht geladen werden.' };
|
||||||
render(Page, { data });
|
render(Page, { data });
|
||||||
await expect
|
await expect.element(page.getByText('Daten konnten nicht geladen werden.')).toBeInTheDocument();
|
||||||
.element(page.getByText('Daten konnten nicht geladen werden.'))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
await page.screenshot({ path: 'test-results/screenshots/home-error.png' });
|
await page.screenshot({ path: 'test-results/screenshots/home-error.png' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import { createApiClient } from '$lib/api.server';
|
|||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export async function load({ url, fetch }) {
|
export async function load({ url, fetch }) {
|
||||||
const q = url.searchParams.get('q') || '';
|
const q = url.searchParams.get('q') || '';
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const result = await api.GET('/api/persons', {
|
const result = await api.GET('/api/persons', {
|
||||||
params: { query: { q: q || undefined } }
|
params: { query: { q: q || undefined } }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
throw error(result.response.status, getErrorMessage(undefined));
|
throw error(result.response.status, getErrorMessage(undefined));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { persons: result.data!, q };
|
return { persons: result.data!, q };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,13 +26,18 @@ function handleSearch(e: Event) {
|
|||||||
{m.persons_subtitle()}
|
{m.persons_subtitle()}
|
||||||
</p>
|
</p>
|
||||||
{#if data.canWrite}
|
{#if data.canWrite}
|
||||||
<a
|
<a
|
||||||
href="/persons/new"
|
href="/persons/new"
|
||||||
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
||||||
>
|
>
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg" alt="" aria-hidden="true" class="h-4 w-4" />
|
<img
|
||||||
{m.persons_btn_new()}
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||||
</a>
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{m.persons_btn_new()}
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -51,7 +56,12 @@ function handleSearch(e: Event) {
|
|||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400"
|
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400"
|
||||||
>
|
>
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg" alt="" aria-hidden="true" class="h-4 w-4 opacity-40" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 opacity-40"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,14 +74,19 @@ function handleSearch(e: Event) {
|
|||||||
<div
|
<div
|
||||||
class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-brand-sand/30 text-brand-navy"
|
class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-brand-sand/30 text-brand-navy"
|
||||||
>
|
>
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg" alt="" aria-hidden="true" class="h-6 w-6" />
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-6 w-6"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="font-serif text-lg text-brand-navy">{m.persons_empty_heading()}</p>
|
<p class="font-serif text-lg text-brand-navy">{m.persons_empty_heading()}</p>
|
||||||
<p class="mt-1 font-sans text-sm text-gray-500">{m.persons_empty_text()}</p>
|
<p class="mt-1 font-sans text-sm text-gray-500">{m.persons_empty_text()}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
{#each data.persons as person}
|
{#each data.persons as person (person.id)}
|
||||||
<a href="/persons/{person.id}" class="group block h-full">
|
<a href="/persons/{person.id}" class="group block h-full">
|
||||||
<div
|
<div
|
||||||
class="relative flex h-full items-center gap-4 overflow-hidden rounded border border-brand-sand bg-white p-6 shadow-sm transition-all duration-200 hover:border-brand-navy hover:shadow-md"
|
class="relative flex h-full items-center gap-4 overflow-hidden rounded border border-brand-sand bg-white p-6 shadow-sm transition-all duration-200 hover:border-brand-navy hover:shadow-md"
|
||||||
|
|||||||
@@ -3,78 +3,79 @@ import { createApiClient } from '$lib/api.server';
|
|||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export async function load({ params, fetch }) {
|
export async function load({ params, fetch }) {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const [personResult, sentDocsResult, receivedDocsResult] = await Promise.all([
|
const [personResult, sentDocsResult, receivedDocsResult] = await Promise.all([
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } })
|
api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!personResult.response.ok) {
|
if (!personResult.response.ok) {
|
||||||
const code = (personResult.error as unknown as { code?: string })?.code;
|
const code = (personResult.error as unknown as { code?: string })?.code;
|
||||||
throw error(personResult.response.status, getErrorMessage(code));
|
throw error(personResult.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
person: personResult.data!,
|
person: personResult.data!,
|
||||||
sentDocuments: sentDocsResult.data ?? [],
|
sentDocuments: sentDocsResult.data ?? [],
|
||||||
receivedDocuments: receivedDocsResult.data ?? []
|
receivedDocuments: receivedDocsResult.data ?? []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
update: async ({ request, params, fetch }) => {
|
update: async ({ request, params, fetch }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const firstName = formData.get('firstName')?.toString().trim();
|
const firstName = formData.get('firstName')?.toString().trim();
|
||||||
const lastName = formData.get('lastName')?.toString().trim();
|
const lastName = formData.get('lastName')?.toString().trim();
|
||||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||||
const birthYear = formData.get('birthYear')?.toString().trim() || undefined;
|
const birthYear = formData.get('birthYear')?.toString().trim() || undefined;
|
||||||
const deathYear = formData.get('deathYear')?.toString().trim() || undefined;
|
const deathYear = formData.get('deathYear')?.toString().trim() || undefined;
|
||||||
|
|
||||||
if (!firstName || !lastName) {
|
if (!firstName || !lastName) {
|
||||||
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
|
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
const { error: apiError } = await api.PUT('/api/persons/{id}', {
|
const { error: apiError } = await api.PUT('/api/persons/{id}', {
|
||||||
params: { path: { id: params.id } },
|
params: { path: { id: params.id } },
|
||||||
body: {
|
body: {
|
||||||
firstName, lastName,
|
firstName,
|
||||||
...(alias ? { alias } : {}),
|
lastName,
|
||||||
...(notes ? { notes } : {}),
|
...(alias ? { alias } : {}),
|
||||||
...(birthYear ? { birthYear } : {}),
|
...(notes ? { notes } : {}),
|
||||||
...(deathYear ? { deathYear } : {})
|
...(birthYear ? { birthYear } : {}),
|
||||||
}
|
...(deathYear ? { deathYear } : {})
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (apiError) {
|
if (apiError) {
|
||||||
return fail(400, { updateError: 'Speichern fehlgeschlagen.' });
|
return fail(400, { updateError: 'Speichern fehlgeschlagen.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { updated: true };
|
return { updated: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
merge: async ({ request, params, fetch }) => {
|
merge: async ({ request, params, fetch }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const targetPersonId = formData.get('targetPersonId')?.toString();
|
const targetPersonId = formData.get('targetPersonId')?.toString();
|
||||||
|
|
||||||
if (!targetPersonId) {
|
if (!targetPersonId) {
|
||||||
return fail(400, { mergeError: 'Bitte eine Zielperson auswählen.' });
|
return fail(400, { mergeError: 'Bitte eine Zielperson auswählen.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
const { error: apiError } = await api.POST('/api/persons/{id}/merge', {
|
const { error: apiError } = await api.POST('/api/persons/{id}/merge', {
|
||||||
params: { path: { id: params.id } },
|
params: { path: { id: params.id } },
|
||||||
body: { targetPersonId }
|
body: { targetPersonId }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (apiError) {
|
if (apiError) {
|
||||||
return fail(400, { mergeError: 'Zusammenführen fehlgeschlagen.' });
|
return fail(400, { mergeError: 'Zusammenführen fehlgeschlagen.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
throw redirect(303, `/persons/${targetPersonId}`);
|
throw redirect(303, `/persons/${targetPersonId}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,11 @@ import { error, fail, redirect } from '@sveltejs/kit';
|
|||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
|
||||||
export async function load({ locals }: { locals: App.Locals }) {
|
export async function load({ locals }: { locals: App.Locals }) {
|
||||||
const canWrite = locals.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('WRITE_ALL')) ?? false;
|
const canWrite =
|
||||||
if (!canWrite) throw error(403, 'Forbidden');
|
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||||
|
g.permissions.includes('WRITE_ALL')
|
||||||
|
) ?? false;
|
||||||
|
if (!canWrite) throw error(403, 'Forbidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
brand: {
|
brand: {
|
||||||
navy: '#002850', // Header & Hero background
|
navy: '#002850', // Header & Hero background
|
||||||
mint: '#A6DAD8', // The Comma accent color
|
mint: '#A6DAD8', // The Comma accent color
|
||||||
sand: '#E4E2D7', // Content background
|
sand: '#E4E2D7', // Content background
|
||||||
white: '#ffffff',
|
white: '#ffffff'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
// Montserrat for UI/Headers, Merriweather for Body text (as established previously)
|
// Montserrat for UI/Headers, Merriweather for Body text (as established previously)
|
||||||
sans: ['Montserrat', 'sans-serif'],
|
sans: ['Montserrat', 'sans-serif'],
|
||||||
serif: ['Merriweather', 'serif'],
|
serif: ['Merriweather', 'serif']
|
||||||
},
|
},
|
||||||
fontSize: {
|
fontSize: {
|
||||||
'huge': '4rem', // For the large stats numbers (e.g., "29", "5000+")
|
huge: '4rem' // For the large stats numbers (e.g., "29", "5000+")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: []
|
plugins: []
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0', // Erlaubt Zugriff von außen
|
host: '0.0.0.0', // Erlaubt Zugriff von außen
|
||||||
port: 5173, // Standard SvelteKit Port
|
port: 5173, // Standard SvelteKit Port
|
||||||
// Proxy für API-Aufrufe während der Entwicklung (Browser -> Vite -> Spring Boot)
|
// Proxy für API-Aufrufe während der Entwicklung (Browser -> Vite -> Spring Boot)
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
@@ -37,8 +37,8 @@ export default defineConfig({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
provider: playwright(),
|
provider: playwright(),
|
||||||
instances: [{ browser: 'chromium', headless: true }],
|
instances: [{ browser: 'chromium', headless: true }],
|
||||||
screenshotDirectory: 'test-results/screenshots',
|
screenshotDirectory: 'test-results/screenshots',
|
||||||
screenshotFailures: true
|
screenshotFailures: true
|
||||||
},
|
},
|
||||||
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||||
exclude: ['src/lib/server/**']
|
exclude: ['src/lib/server/**']
|
||||||
|
|||||||
Reference in New Issue
Block a user