Compare commits
103 Commits
b583c8489d
...
fix/svelte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9731afb776 | ||
|
|
f6634f1d00 | ||
|
|
18601db4f8 | ||
|
|
a65c69b0ce | ||
|
|
8f5c13f162 | ||
|
|
168225d67c | ||
|
|
401a1f359f | ||
|
|
82c8401167 | ||
|
|
2f803b2740 | ||
|
|
da0d5495d0 | ||
|
|
513a7290b0 | ||
|
|
0f8b582813 | ||
|
|
4026bb9003 | ||
|
|
f2f9a1bf03 | ||
|
|
76031de8eb | ||
|
|
e2874528cd | ||
|
|
aa127de9bd | ||
|
|
65a8048e25 | ||
|
|
1ab063486c | ||
|
|
0a1075e03f | ||
|
|
ca212e871f | ||
|
|
0525e66d55 | ||
|
|
acf6fc05ad | ||
|
|
f950e4e826 | ||
|
|
db2fc33e99 | ||
|
|
28dea45cc3 | ||
|
|
11f6f9e2a2 | ||
|
|
4771832492 | ||
|
|
c006113db9 | ||
|
|
5160009175 | ||
|
|
4009781064 | ||
|
|
761c903111 | ||
|
|
931a8dac95 | ||
|
|
3f717e3266 | ||
|
|
203b7d2b08 | ||
|
|
e9b03ee6a9 | ||
|
|
ba04e62f87 | ||
|
|
fa4bfb8e5c | ||
|
|
fde75f3fcf | ||
|
|
03a1a86cdb | ||
|
|
55ffaa1c5c | ||
|
|
1fdde95b09 | ||
|
|
c056d804e6 | ||
|
|
490382b5de | ||
|
|
557b62ac5c | ||
|
|
4ccc8d69d0 | ||
|
|
c3f487f16c | ||
|
|
6e6663376d | ||
|
|
041bbdc2e6 | ||
|
|
08f7ae9a5c | ||
|
|
c01a07bd82 | ||
|
|
cccc12429c | ||
|
|
b07391541b | ||
|
|
fb08eb30a4 | ||
|
|
371d92f52a | ||
|
|
fe9b4a9569 | ||
|
|
7988c62246 | ||
|
|
a998ef4550 | ||
|
|
c4be2eb46e | ||
|
|
20313de4e9 | ||
|
|
4142c7cd83 | ||
|
|
0e76be5672 | ||
|
|
db6dc28528 | ||
|
|
208dc87d69 | ||
|
|
d943372ea7 | ||
|
|
4e8de3658f | ||
|
|
b6361e6cbc | ||
|
|
a3948b6a0f | ||
|
|
0918e75803 | ||
|
|
56cbd290e3 | ||
|
|
9f3f022ec0 | ||
|
|
6ef7b292cc | ||
|
|
a65cbf9bae | ||
|
|
a60905674f | ||
|
|
e6db43850b | ||
|
|
802f1ab0e0 | ||
|
|
9b67db74eb | ||
|
|
3280125140 | ||
|
|
553fa8a4b9 | ||
|
|
409e70078c | ||
|
|
5f36930b6b | ||
|
|
b456c8f1bd | ||
|
|
3f987ca48f | ||
|
|
225d6e44c9 | ||
|
|
ded5c24c40 | ||
|
|
f4e6fe587c | ||
|
|
0d0aa83c0c | ||
|
|
dcb26e201a | ||
|
|
fcaf6dc322 | ||
|
|
f59f4d304a | ||
|
|
f2030ca4ee | ||
|
|
49fd29c1e6 | ||
|
|
3b04f4cafe | ||
|
|
ed32e1728d | ||
|
|
273b43261f | ||
|
|
7cb20dec50 | ||
|
|
973620a097 | ||
|
|
b3de5f885d | ||
|
|
4417fc9828 | ||
|
|
25e095ea47 | ||
|
|
97e5255d7f | ||
|
|
6b5c78f789 | ||
|
|
0123dffdc4 |
199
.gitea/workflows/ci.yml
Normal file
199
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,199 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
# ─── Unit & Browser Component Tests ──────────────────────────────────────────
|
||||
# Runs inside the official Playwright Docker image — Chromium and all system
|
||||
# deps are pre-installed, so no install or cache step is needed for the browser.
|
||||
unit-tests:
|
||||
name: Unit & Component Tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run unit and component tests
|
||||
run: npm test
|
||||
working-directory: frontend
|
||||
|
||||
- name: Upload screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: unit-test-screenshots
|
||||
path: frontend/test-results/screenshots/
|
||||
|
||||
# ─── Backend Unit & Slice Tests ───────────────────────────────────────────────
|
||||
# Pure Mockito + WebMvcTest — no DB or S3 needed.
|
||||
backend-unit-tests:
|
||||
name: Backend Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: temurin
|
||||
|
||||
- name: Cache Maven repository
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: maven-${{ hashFiles('backend/pom.xml') }}
|
||||
restore-keys: maven-
|
||||
|
||||
- name: Run backend tests
|
||||
run: |
|
||||
chmod +x mvnw
|
||||
./mvnw clean test
|
||||
working-directory: backend
|
||||
|
||||
# ─── E2E Tests ────────────────────────────────────────────────────────────────
|
||||
# Needs: PostgreSQL + MinIO (via docker-compose) + Spring Boot + SvelteKit dev server.
|
||||
# Test data is seeded by DataInitializer on first startup (admin user + e2e profile data).
|
||||
e2e-tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# These env vars are picked up by docker-compose (overrides .env file)
|
||||
env:
|
||||
DOCKER_API_VERSION: "1.43"
|
||||
POSTGRES_USER: archive_user
|
||||
POSTGRES_PASSWORD: ci_db_password
|
||||
POSTGRES_DB: family_archive_db
|
||||
MINIO_ROOT_USER: minio_admin
|
||||
MINIO_ROOT_PASSWORD: ci_minio_password
|
||||
MINIO_DEFAULT_BUCKETS: archive-documents
|
||||
PORT_DB: 5433
|
||||
PORT_MINIO_API: 9100
|
||||
PORT_MINIO_CONSOLE: 9101
|
||||
PORT_BACKEND: 8080
|
||||
PORT_FRONTEND: 3000
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ── Infrastructure ──────────────────────────────────────────────────────
|
||||
- name: Cleanup leftover containers from previous runs
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down --volumes --remove-orphans || true
|
||||
|
||||
- name: Start DB and MinIO
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d db minio create-buckets
|
||||
|
||||
- name: Wait for DB to be ready
|
||||
run: |
|
||||
timeout 30 bash -c \
|
||||
'until docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T db pg_isready -U archive_user; do sleep 2; done'
|
||||
|
||||
- name: Connect job container to compose network
|
||||
run: docker network connect familienarchiv_archive-net $(cat /etc/hostname)
|
||||
|
||||
# ── Backend ─────────────────────────────────────────────────────────────
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: temurin
|
||||
|
||||
- name: Cache Maven repository
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: maven-${{ hashFiles('backend/pom.xml') }}
|
||||
restore-keys: maven-
|
||||
|
||||
- name: Build backend (skip tests — covered by separate Java test job)
|
||||
run: |
|
||||
chmod +x mvnw
|
||||
./mvnw clean package -DskipTests
|
||||
working-directory: backend
|
||||
|
||||
- name: Start backend
|
||||
run: |
|
||||
java -jar backend/target/*.jar \
|
||||
--spring.profiles.active=e2e \
|
||||
--SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/family_archive_db \
|
||||
--SPRING_DATASOURCE_USERNAME=archive_user \
|
||||
--SPRING_DATASOURCE_PASSWORD=ci_db_password \
|
||||
--S3_ENDPOINT=http://minio:9000 \
|
||||
--S3_ACCESS_KEY=minio_admin \
|
||||
--S3_SECRET_KEY=ci_minio_password \
|
||||
--S3_BUCKET_NAME=archive-documents \
|
||||
--S3_REGION=us-east-1 \
|
||||
--APP_ADMIN_USERNAME=admin \
|
||||
--APP_ADMIN_PASSWORD=admin123 \
|
||||
&
|
||||
echo "Waiting for backend..."
|
||||
timeout 90 bash -c \
|
||||
'until curl -sf http://localhost:8080/actuator/health | grep -q "UP"; do sleep 3; done'
|
||||
echo "Backend is up."
|
||||
|
||||
# ── Frontend ─────────────────────────────────────────────────────────────
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
|
||||
|
||||
- name: Install frontend dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ hashFiles('frontend/package-lock.json') }}
|
||||
|
||||
- name: Install Playwright Chromium + system deps
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: frontend
|
||||
|
||||
- name: Install Playwright system deps (browser binary already cached)
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps chromium
|
||||
working-directory: frontend
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────────────
|
||||
- name: Run E2E tests
|
||||
run: npm run test:e2e
|
||||
working-directory: frontend
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
E2E_USERNAME: admin
|
||||
E2E_PASSWORD: admin123
|
||||
|
||||
- name: Upload E2E results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: e2e-results
|
||||
path: frontend/test-results/e2e/
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,9 +1,14 @@
|
||||
# Runtime data (Docker volumes)
|
||||
data/
|
||||
import-data/
|
||||
import/
|
||||
gitea/
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
# Dev scripts / DB dumps
|
||||
scripts/large-data.sql
|
||||
|
||||
.vitest-attachments
|
||||
**/test-results/
|
||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
||||
cd frontend && npm run lint
|
||||
321
CLAUDE.md
321
CLAUDE.md
@@ -4,23 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Familienarchiv** is a family document archival system — a full-stack web app for digitizing, organizing, and searching family documents. Key features: file uploads (stored in MinIO/S3), metadata management, Excel batch import, full-text search, conversation threads between family members, and role-based access control.
|
||||
**Familienarchiv** is a family document archival system — a full-stack web app for digitizing, organizing, and searching family documents. Key features: file uploads (stored in MinIO/S3), metadata management, Excel/ODS batch import, full-text search, conversation threads between family members, and role-based access control.
|
||||
|
||||
## Collaboration Principles
|
||||
## Collaboration
|
||||
|
||||
**Be honest and objective**: Evaluate all suggestions, ideas, and feedback on their technical merits. Don't be overly complimentary or sycophantic. If something doesn't make sense, doesn't align with best practices, or could be improved, say so directly and constructively. Technical accuracy and project quality take precedence over being agreeable.
|
||||
See [COLLABORATING.md](./COLLABORATING.md) for the full rules: issue tracking workflow, commit message conventions, and the Research → Plan → Implement → Validate cycle.
|
||||
|
||||
## Core Workflow: Research → Plan → Implement → Validate
|
||||
See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS trade-offs (KISS wins), and SOLID principles applied to this stack.
|
||||
|
||||
**Start every feature with:** "Let me research the codebase and create a plan before implementing."
|
||||
|
||||
1. **Research** - Understand existing patterns and architecture
|
||||
2. **Plan** - Propose approach and verify with you
|
||||
3. **Implement** - Build with tests and error handling
|
||||
4. **Validate** - ALWAYS run formatters, linters, and tests after implementation
|
||||
|
||||
- Whenever working on a feature or issue, let's always come up with a plan first, then save it to a file called `/.agent/current-plan.md`, before getting started with code changes. Update this file as the work progresses.
|
||||
- Let's use pure functions where possible to improve readability and testing.
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -62,48 +54,299 @@ npm run lint # Prettier + ESLint check
|
||||
npm run format # Auto-fix formatting
|
||||
npm run check # svelte-check (type checking)
|
||||
npm run test # Vitest unit tests
|
||||
npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
|
||||
# (requires backend running with --spring.profiles.active=dev)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
---
|
||||
|
||||
### Backend (`backend/src/main/java/org/raddatz/familienarchiv/`)
|
||||
## Backend Architecture
|
||||
|
||||
Layered architecture:
|
||||
### Package Structure
|
||||
|
||||
- **`model/`** — JPA entities: `Document`, `Person`, `AppUser`, `UserGroup`, `Tag`, `DocumentStatus` (enum: PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED)
|
||||
- **`repository/`** — Spring Data repositories + `DocumentSpecifications` for complex filtered queries
|
||||
- **`service/`** — Core business logic: `DocumentService` (uploads, search, Excel import), `FileService` (MinIO/S3), `ExcelService`, `MassImportService`, `UserService`
|
||||
- **`controller/`** — REST endpoints: `DocumentController`, `PersonController`, `UserController`, `AdminController`, `GroupController`, `TagController`
|
||||
- **`security/`** — `SecurityConfig` (HTTP Basic + form login, CSRF disabled), `PermissionAspect` (AOP enforcement of `@RequirePermission`), `CustomUserDetailsService`
|
||||
- **`config/`** — `MinioConfig` (creates S3Client, validates connectivity on startup), `AsyncConfig`
|
||||
```
|
||||
backend/src/main/java/org/raddatz/familienarchiv/
|
||||
├── controller/ REST endpoints — thin, delegate everything to services
|
||||
├── service/ Business logic — the only place that touches repositories
|
||||
├── repository/ Spring Data JPA interfaces
|
||||
├── model/ JPA entities
|
||||
├── dto/ Input objects (request bodies/form data)
|
||||
├── exception/ DomainException + ErrorCode enum
|
||||
├── security/ SecurityConfig, Permission enum, @RequirePermission, PermissionAspect
|
||||
└── config/ MinioConfig, AsyncConfig
|
||||
```
|
||||
|
||||
Database migrations live in `src/main/resources/db/migration/` (Flyway). Configuration in `src/main/resources/application.properties` — most values injected from environment variables (DB credentials, MinIO endpoint/credentials/bucket, upload limits, Excel column mappings).
|
||||
### Layering Rules (strictly enforced)
|
||||
|
||||
### Frontend (`backend/workspaces/frontend/src/`)
|
||||
```
|
||||
Controller → Service → Repository → DB
|
||||
```
|
||||
|
||||
- **`hooks.server.ts`** — Central middleware: reads `auth_token` cookie, injects it into all API calls to the backend, loads current user context
|
||||
- **`routes/`** — File-based routing. Main pages: `/` (search/home), `/documents/[id]`, `/documents/[id]/edit`, `/persons`, `/persons/[id]`, `/conversations`, `/admin`, `/login`
|
||||
- **`routes/api/`** — SvelteKit API endpoints for typeahead (persons, tags) — these call the Spring Boot backend
|
||||
- **`lib/components/`** — `PersonTypeahead.svelte`, `TagInput.svelte`
|
||||
- **`messages/`** — Paraglide.js translation files (`de.json`, `en.json`, `es.json`)
|
||||
- **Controllers** never inject or call repositories directly.
|
||||
- **Services** never reach into another domain's repository. Call the other domain's service instead.
|
||||
- ✅ `DocumentService` → `PersonService.getById()` → `PersonRepository`
|
||||
- ❌ `DocumentService` → `PersonRepository` directly
|
||||
- This keeps domain boundaries clear and business logic testable in isolation.
|
||||
|
||||
Authentication: form login → backend sets session → `auth_token` cookie → hooks.server.ts injects into all backend requests.
|
||||
### Domain Model
|
||||
|
||||
### Key Design Patterns
|
||||
| Entity | Table | Key relationships |
|
||||
|---|---|---|
|
||||
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
||||
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
||||
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
||||
|
||||
- **Search**: `DocumentSpecifications` (Spring Data JPA Specification pattern) enables composable, dynamic query building for the document search endpoint
|
||||
- **Permissions**: `@RequirePermission` annotation processed by `PermissionAspect` (AOP) — checks user's `UserGroup` permissions at the method level
|
||||
- **Excel Import**: Configurable column index mapping in `application.properties`; `ExcelService` parses → `MassImportService` upserts documents
|
||||
- **File Storage**: `FileService` wraps AWS SDK v2 `S3Client` with path-style access for MinIO compatibility
|
||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||
|
||||
### Infrastructure
|
||||
- `PLACEHOLDER`: created during Excel import, no file yet
|
||||
- `UPLOADED`: file has been stored in S3
|
||||
|
||||
The `docker-compose.yml` at the repo root orchestrates everything. A MinIO MC helper container runs at startup to create the `archive-documents` bucket and set permissions. The backend container depends on both `db` and `minio` being healthy before starting.
|
||||
### Entity Code Style
|
||||
|
||||
All entities use these Lombok annotations:
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "table_name")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class MyEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) // marks field as required in OpenAPI spec
|
||||
private UUID id;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
- `@Schema(requiredMode = REQUIRED)` must be added to every field the backend always populates (id, non-null fields). This drives the TypeScript type generation.
|
||||
- Collections use `@Builder.Default` with `new HashSet<>()` as the default.
|
||||
- Timestamps use `@CreationTimestamp` / `@UpdateTimestamp`.
|
||||
|
||||
### Services
|
||||
|
||||
Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optionally `@Slf4j`.
|
||||
|
||||
- Write methods are annotated `@Transactional`.
|
||||
- Read methods are not annotated (default non-transactional is fine).
|
||||
- Each service owns its domain's repository. Cross-domain data access goes through the other domain's service.
|
||||
|
||||
**Existing services:**
|
||||
|
||||
| Service | Responsibility |
|
||||
|---|---|
|
||||
| `DocumentService` | Document CRUD, search, tag cascade delete |
|
||||
| `PersonService` | Person CRUD, find-or-create by alias |
|
||||
| `TagService` | Tag find/create/update/delete |
|
||||
| `UserService` | User and group CRUD |
|
||||
| `FileService` | S3/MinIO upload and download |
|
||||
| `MassImportService` | Async ODS/Excel import; delegates to PersonService and TagService |
|
||||
| `ExcelService` | Lower-level spreadsheet parsing |
|
||||
|
||||
### DTOs
|
||||
|
||||
Input DTOs live in `dto/`. Response types are the model entities themselves (no response DTOs).
|
||||
|
||||
- `DocumentUpdateDTO` — used for both create and update (all fields optional)
|
||||
- `CreateUserRequest` — user creation
|
||||
- `GroupDTO` — group create/update
|
||||
|
||||
### Error Handling
|
||||
|
||||
Use `DomainException` for all domain errors. Never throw raw exceptions from service methods.
|
||||
|
||||
```java
|
||||
// Static factories match common HTTP status codes:
|
||||
DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)
|
||||
DomainException.forbidden("Access denied")
|
||||
DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "Already running")
|
||||
DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Upload failed: " + e.getMessage())
|
||||
```
|
||||
|
||||
`ErrorCode` is an enum in `exception/ErrorCode.java`. When adding a new error case, add the value there **and** mirror it in the frontend's `src/lib/errors.ts` + add a Paraglide translation key.
|
||||
|
||||
For simple validation in controllers (not domain logic), `ResponseStatusException` is acceptable:
|
||||
```java
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "firstName is required");
|
||||
```
|
||||
|
||||
### Security / Permissions
|
||||
|
||||
Use `@RequirePermission` on controller methods (or the whole controller class):
|
||||
|
||||
```java
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public Document updateDocument(...) { ... }
|
||||
```
|
||||
|
||||
Available permissions: `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`
|
||||
|
||||
`PermissionAspect` (AOP) checks the current user's `UserGroup.permissions` at runtime.
|
||||
|
||||
### OpenAPI / API Types
|
||||
|
||||
SpringDoc generates the spec at `/v3/api-docs` (only accessible when running with `--spring.profiles.active=dev`).
|
||||
|
||||
When changing any model field or endpoint:
|
||||
1. Rebuild the backend JAR with `-DskipTests`
|
||||
2. Start it with `--spring.profiles.active=dev`
|
||||
3. Run `npm run generate:api` in `frontend/`
|
||||
|
||||
---
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### Route Structure
|
||||
|
||||
```
|
||||
frontend/src/routes/
|
||||
├── +layout.svelte Global header (sticky), nav links, logout
|
||||
├── +layout.server.ts Loads current user, injects auth cookie
|
||||
├── +page.svelte Home / document search
|
||||
├── +page.server.ts Load: search documents; no actions
|
||||
├── documents/
|
||||
│ ├── [id]/+page.svelte Document detail (view + file preview)
|
||||
│ └── [id]/edit/ Edit form (all metadata + file upload)
|
||||
│ └── new/ Create form (same fields, empty)
|
||||
├── persons/
|
||||
│ ├── +page.svelte Person list with search
|
||||
│ ├── [id]/+page.svelte Person detail (inline edit + merge)
|
||||
│ └── new/ Create person form
|
||||
├── conversations/ Bilateral conversation timeline
|
||||
├── admin/ User + group + tag management
|
||||
└── login/ logout/ Auth pages
|
||||
```
|
||||
|
||||
### API Client Pattern
|
||||
|
||||
All server-side API calls use the typed client from `$lib/api.server.ts`:
|
||||
|
||||
```typescript
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
|
||||
|
||||
// Always check via response.ok, NOT result.error
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
return { person: result.data! };
|
||||
```
|
||||
|
||||
Key rules:
|
||||
- Use `!result.response.ok` for error checking (not `if (result.error)` — this breaks when the spec has no error responses defined)
|
||||
- Cast errors as `result.error as unknown as { code?: string }` to extract the backend error code
|
||||
- Use `result.data!` (non-null assertion) after an ok check — TypeScript knows it's present
|
||||
|
||||
For multipart/form-data endpoints (file uploads), bypass the typed client and use raw `fetch`:
|
||||
```typescript
|
||||
const res = await fetch(`${baseUrl}/api/documents`, { method: 'POST', body: formData });
|
||||
```
|
||||
|
||||
### Form Actions Pattern
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
export const actions = {
|
||||
default: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string; // cast needed — FormData returns FormDataEntryValue
|
||||
// ...
|
||||
return fail(400, { error: 'message' }); // on error
|
||||
throw redirect(303, '/target'); // on success
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Date Handling
|
||||
|
||||
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO format to the backend.
|
||||
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC timezone off-by-one:
|
||||
```typescript
|
||||
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
.format(new Date(doc.documentDate + 'T12:00:00'))
|
||||
```
|
||||
|
||||
### UI Component Library
|
||||
|
||||
Custom components in `src/lib/components/`:
|
||||
|
||||
| Component | Props | Description |
|
||||
|---|---|---|
|
||||
| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead dropdown |
|
||||
| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector |
|
||||
| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead |
|
||||
|
||||
### Styling Conventions (Tailwind CSS 4)
|
||||
|
||||
Brand color utilities (defined in `layout.css`):
|
||||
|
||||
| Class | Value | Usage |
|
||||
|---|---|---|
|
||||
| `brand-navy` | `#002850` | Primary text, buttons, headers |
|
||||
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons |
|
||||
| `brand-sand` | `#E4E2D7` | Page background, card borders |
|
||||
|
||||
Typography:
|
||||
- `font-serif` (Merriweather) — body text, document titles, names
|
||||
- `font-sans` (Montserrat) — labels, metadata, UI chrome
|
||||
|
||||
Card pattern for content sections:
|
||||
```svelte
|
||||
<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">Section Title</h2>
|
||||
<!-- content -->
|
||||
</div>
|
||||
```
|
||||
|
||||
Save bar pattern — use **sticky full-bleed** for long forms (edit document), **card-style with `mt-4`** for short forms (new person):
|
||||
```svelte
|
||||
<!-- Long forms: sticky, full-bleed -->
|
||||
<div class="sticky bottom-0 z-10 -mx-4 px-6 py-4 bg-white border-t border-brand-sand shadow-[0_-2px_8px_rgba(0,0,0,0.06)] flex items-center justify-between">
|
||||
|
||||
<!-- Short forms: card, top margin -->
|
||||
<div class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm">
|
||||
```
|
||||
|
||||
Back link pattern:
|
||||
```svelte
|
||||
<a href="/persons" class="inline-flex items-center text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy transition-colors group mb-4">
|
||||
<svg class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" .../>
|
||||
Zurück zur Übersicht
|
||||
</a>
|
||||
```
|
||||
|
||||
Subtle action link (e.g. "new document/person"):
|
||||
```svelte
|
||||
<a href="/documents/new" class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 hover:text-brand-navy transition-colors">
|
||||
<svg class="w-4 h-4" ...><!-- plus icon --></svg>
|
||||
Neues Dokument
|
||||
</a>
|
||||
```
|
||||
|
||||
### Error Handling (Frontend)
|
||||
|
||||
`src/lib/errors.ts` mirrors the backend `ErrorCode` enum and maps codes to Paraglide translation keys. When adding a new `ErrorCode` on the backend:
|
||||
1. Add it to `ErrorCode.java`
|
||||
2. Add it to the `ErrorCode` type in `errors.ts`
|
||||
3. Add a `case` in `getErrorMessage()`
|
||||
4. Add the translation key in `messages/de.json`, `en.json`, `es.json`
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
The `docker-compose.yml` at the repo root orchestrates everything. A MinIO MC helper container runs at startup to create the `archive-documents` bucket. The backend container depends on both `db` and `minio` being healthy.
|
||||
|
||||
Database migrations live in `backend/src/main/resources/db/migration/` (Flyway, SQL files named `V{n}__{description}.sql`).
|
||||
|
||||
## API Testing
|
||||
|
||||
HTTP test files are in `backend/api_tests/` (`Document.http`, `User.http`) for use with the VS Code REST Client extension.
|
||||
HTTP test files are in `backend/api_tests/` for use with the VS Code REST Client extension.
|
||||
|
||||
## Dev Container
|
||||
|
||||
A `.devcontainer/` config is available (Java 21 + Node 24, with ports 8080 and 3000 forwarded). Use VS Code's "Reopen in Container" to get a pre-configured environment with Spring Boot Tools, Lombok support, and database/MinIO services running.
|
||||
A `.devcontainer/` config is available (Java 21 + Node 24, ports 8080 and 3000 forwarded). Use VS Code's "Reopen in Container" for a pre-configured environment.
|
||||
|
||||
329
CODESTYLE.md
Normal file
329
CODESTYLE.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Code Style Guide
|
||||
|
||||
This document defines the coding standards for the Familienarchiv project. It applies to both the Java backend and the TypeScript/Svelte frontend. When in doubt, prefer code that a competent developer can read and understand without explanation.
|
||||
|
||||
---
|
||||
|
||||
## Clean Code (Uncle Bob)
|
||||
|
||||
These are principles, not laws. Apply judgment.
|
||||
|
||||
### Names reveal intent
|
||||
|
||||
A name should tell you *why* something exists, what it does, and how it is used — without needing a comment to explain it.
|
||||
|
||||
```java
|
||||
// Bad
|
||||
int d; // elapsed time in days
|
||||
List<Document> list2;
|
||||
|
||||
// Good
|
||||
int elapsedDays;
|
||||
List<Document> receivedDocuments;
|
||||
```
|
||||
|
||||
- No abbreviations unless universally understood (`id`, `url`, `dto`).
|
||||
- Boolean variables and methods should read as yes/no questions: `isEnabled`, `hasFile`, `canWrite`.
|
||||
- Avoid redundant context: inside class `Document`, write `getTitle()` not `getDocumentTitle()`.
|
||||
|
||||
### Functions do one thing
|
||||
|
||||
A function that does one thing can rarely be meaningfully subdivided. If you can extract a chunk with a name that isn't just a restatement of what it does, it should probably be its own function.
|
||||
|
||||
```java
|
||||
// Bad — validates, transforms, and persists
|
||||
public Document saveDocument(DocumentUpdateDTO dto) {
|
||||
if (dto.getTitle() == null) throw new DomainException(...);
|
||||
String cleaned = dto.getTitle().strip();
|
||||
Document doc = documentRepository.findById(...).orElseThrow(...);
|
||||
doc.setTitle(cleaned);
|
||||
return documentRepository.save(doc);
|
||||
}
|
||||
|
||||
// Good — one responsibility per method; caller orchestrates
|
||||
public Document updateDocument(UUID id, DocumentUpdateDTO dto) {
|
||||
validateTitle(dto.getTitle());
|
||||
Document doc = getById(id);
|
||||
applyUpdate(doc, dto);
|
||||
return documentRepository.save(doc);
|
||||
}
|
||||
```
|
||||
|
||||
### Small functions, minimal parameters
|
||||
|
||||
- Functions longer than ~20 lines are a signal to look for a natural split.
|
||||
- Aim for ≤ 3 parameters. More than 3 is a sign the function is doing too much, or parameters should be grouped into an object.
|
||||
- Never use boolean flag arguments — they announce the function does two things:
|
||||
|
||||
```java
|
||||
// Bad
|
||||
renderDocument(doc, true); // what does true mean?
|
||||
|
||||
// Good
|
||||
renderDocumentWithPreview(doc);
|
||||
renderDocumentWithoutPreview(doc);
|
||||
```
|
||||
|
||||
### Guard clauses over deep nesting
|
||||
|
||||
Return or throw early for preconditions. Keep the happy path at the lowest nesting level.
|
||||
|
||||
```java
|
||||
// Bad
|
||||
public Document getDocument(UUID id, AppUser user) {
|
||||
if (id != null) {
|
||||
Document doc = repository.findById(id).orElse(null);
|
||||
if (doc != null) {
|
||||
if (user.canRead(doc)) {
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Good
|
||||
public Document getDocument(UUID id, AppUser user) {
|
||||
if (id == null) throw DomainException.notFound(...);
|
||||
Document doc = repository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(...));
|
||||
if (!user.canRead(doc)) throw DomainException.forbidden(...);
|
||||
return doc;
|
||||
}
|
||||
```
|
||||
|
||||
### Comments: only for *why*, never for *what*
|
||||
|
||||
Code explains what it does. Comments explain why a non-obvious decision was made.
|
||||
|
||||
```typescript
|
||||
// Bad — restates the code
|
||||
// set auth cookie
|
||||
cookies.set('auth_token', authHeader, { path: '/' });
|
||||
|
||||
// Good — explains a non-obvious constraint
|
||||
// secure: false until the deployment is served over HTTPS
|
||||
cookies.set('auth_token', authHeader, { path: '/', secure: false });
|
||||
```
|
||||
|
||||
If you feel compelled to write a comment that explains *what* the code does, rewrite the code until it doesn't need one.
|
||||
|
||||
### No dead code
|
||||
|
||||
Remove commented-out code, unused variables, unused imports, and unreachable branches. Version control is the history — dead code in the file is noise.
|
||||
|
||||
### Command-query separation
|
||||
|
||||
A function either *does something* (command) or *answers something* (query) — not both.
|
||||
|
||||
```typescript
|
||||
// Bad — modifies state and returns a value
|
||||
function addTagAndReturnCount(tag: string): number { ... }
|
||||
|
||||
// Good — separate concerns
|
||||
function addTag(tag: string): void { ... }
|
||||
function getTagCount(): number { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DRY vs KISS — KISS wins
|
||||
|
||||
**DRY** (Don't Repeat Yourself): every piece of knowledge has a single, authoritative representation.
|
||||
|
||||
**KISS** (Keep It Simple, Stupid): prefer the simplest solution that works.
|
||||
|
||||
**When they conflict, KISS wins.** Do not create an abstraction to eliminate duplication unless the abstraction has a clear, stable name and genuinely reduces cognitive load.
|
||||
|
||||
### Practical rules
|
||||
|
||||
**Extract when:**
|
||||
- The same logic appears in 3+ places *and* it has a meaningful name that isn't just a description of the lines it replaces.
|
||||
- The extracted unit is independently testable.
|
||||
- The abstraction makes the call site *more* readable, not less.
|
||||
|
||||
**Don't extract when:**
|
||||
- Two things look similar but might diverge independently — coupling them through an abstraction would make future changes harder.
|
||||
- The extracted function would be used exactly once.
|
||||
- Naming the abstraction requires a long or awkward name.
|
||||
|
||||
```typescript
|
||||
// Three similar lines — do NOT abstract prematurely
|
||||
const sentYearRange = yearRange(sentDocuments);
|
||||
const receivedYearRange = yearRange(receivedDocuments);
|
||||
|
||||
// yearRange() is worth extracting because it has a clear name,
|
||||
// is used in multiple places, and is independently testable.
|
||||
// But if it were only used once, keep it inline.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SOLID Principles
|
||||
|
||||
Applied to this stack.
|
||||
|
||||
### S — Single Responsibility
|
||||
|
||||
Each class, service, or component has one reason to change. In practice:
|
||||
|
||||
- **Backend:** Controllers receive HTTP, delegate everything to services. Services contain business logic, never touch another domain's repository directly.
|
||||
- **Frontend:** Components render UI. Server files (`+page.server.ts`) load and validate data. Don't put business logic in Svelte components.
|
||||
- **Wrong:** A `DocumentService` that also manages user sessions.
|
||||
- **Right:** `DocumentService` owns documents; `UserService` owns users; each is ignorant of the other's internal details.
|
||||
|
||||
### O — Open/Closed
|
||||
|
||||
Code should be open for extension and closed for modification. Prefer adding new code over editing existing code to support new behavior.
|
||||
|
||||
```java
|
||||
// Bad — adding a new export format requires editing this method
|
||||
public byte[] export(String format) {
|
||||
if (format.equals("csv")) { ... }
|
||||
else if (format.equals("pdf")) { ... } // added later, modifies existing method
|
||||
}
|
||||
|
||||
// Good — each format is a separate implementation
|
||||
public interface DocumentExporter {
|
||||
byte[] export(List<Document> documents);
|
||||
}
|
||||
public class CsvExporter implements DocumentExporter { ... }
|
||||
public class PdfExporter implements DocumentExporter { ... }
|
||||
```
|
||||
|
||||
In practice: when adding a variant of existing behavior, reach for a new class/function before editing an existing one.
|
||||
|
||||
### L — Liskov Substitution
|
||||
|
||||
Subtypes must be usable wherever the parent type is expected, without breaking behavior. Concretely:
|
||||
|
||||
- If you extend a service or implement an interface, the subtype must honor the contracts (error cases, return semantics) of the parent.
|
||||
- Don't override a method to make it a no-op or throw unconditionally — that breaks callers who rely on the contract.
|
||||
|
||||
### I — Interface Segregation
|
||||
|
||||
Don't force callers to depend on methods they don't use. Keep interfaces and services focused.
|
||||
|
||||
```java
|
||||
// Bad — DocumentService exposed to ImportService even though import only needs findOrCreate
|
||||
public class MassImportService {
|
||||
private final DocumentService documentService; // 40+ methods, only 2 needed
|
||||
}
|
||||
|
||||
// Good — expose only what's needed via a targeted service method or a narrow interface
|
||||
public class MassImportService {
|
||||
private final PersonService personService; // only needs findOrCreateByName
|
||||
private final TagService tagService; // only needs findOrCreate
|
||||
}
|
||||
```
|
||||
|
||||
### D — Dependency Inversion
|
||||
|
||||
High-level modules should not depend on low-level modules. Both should depend on abstractions.
|
||||
|
||||
- **Backend:** Spring's `@Autowired` / constructor injection handles this. Always inject interfaces or Spring beans, never instantiate services with `new` inside a controller or service.
|
||||
- **Frontend:** Pass data into components via props rather than fetching it inside the component. Components should receive data; server files should supply it.
|
||||
|
||||
```typescript
|
||||
// Bad — component fetches its own data (depends on network/fetch implementation)
|
||||
onMount(async () => {
|
||||
persons = await fetch('/api/persons').then(r => r.json());
|
||||
});
|
||||
|
||||
// Good — data flows in via props from the server load function
|
||||
let { data } = $props(); // data.persons supplied by +page.server.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Formatting and Style Specifics
|
||||
|
||||
These complement the principles above with project-specific conventions.
|
||||
|
||||
### Both Java and TypeScript
|
||||
|
||||
- One concept per line — don't chain side-effects.
|
||||
- No magic numbers — extract named constants.
|
||||
- Fail fast: validate inputs at the boundary (controller / server load), trust internal code.
|
||||
|
||||
### Java (backend)
|
||||
|
||||
- Use `DomainException` static factories for all domain errors — never throw raw `RuntimeException`.
|
||||
- `@Transactional` only on write methods, not reads.
|
||||
- Entities use `@Builder` — construct with builder pattern, not setters, in tests.
|
||||
- Avoid `Optional.get()` without `orElseThrow` — always provide a meaningful exception.
|
||||
|
||||
### TypeScript / Svelte (frontend)
|
||||
|
||||
- `$derived` over `$effect` for computed values — effects are for side-effects only.
|
||||
- Check `!result.response.ok` for API errors, not `result.error` (see CLAUDE.md).
|
||||
- Prefer typed API client calls over raw `fetch` — use raw `fetch` only for multipart uploads.
|
||||
- Svelte component logic in `<script>`, layout/styles in template — no business logic in markup.
|
||||
|
||||
---
|
||||
|
||||
## Svelte 5 — Specific Rules
|
||||
|
||||
These rules are enforced by ESLint (`eslint-plugin-svelte`). Knowing *why* they exist prevents the need to fix violations after the fact.
|
||||
|
||||
### Always key `{#each}` blocks
|
||||
|
||||
Without a key, Svelte tracks list items by array position. When items are added, removed, or reordered, Svelte patches DOM nodes in-place from the top — it never moves the correct node. Component-local state (counters, animation state, focus) becomes permanently attached to the wrong item. This is a silent data integrity bug, not a crash.
|
||||
|
||||
```svelte
|
||||
<!-- Bad — position-based tracking; reordering silently corrupts local state -->
|
||||
{#each documents as doc}
|
||||
<DocumentCard {doc} />
|
||||
{/each}
|
||||
|
||||
<!-- Good — identity-based; each node follows its data through reorders -->
|
||||
{#each documents as doc (doc.id)}
|
||||
<DocumentCard {doc} />
|
||||
{/each}
|
||||
```
|
||||
|
||||
Use `(item.id)` when items have a stable ID. Use the loop index `(i)` only for static lists that will never be reordered. Use `(item)` for primitive lists.
|
||||
|
||||
### Use `$derived` for computed values, never `$state` + `$effect`
|
||||
|
||||
`$effect` is for *side effects* (DOM calls, network, logging). Using it to assign a computed value introduces a timing problem: `$derived` updates synchronously before the render, while `$effect` runs *after* the render — meaning the component briefly displays a stale value. It also triggers a second reactive pass, doubling the work.
|
||||
|
||||
```svelte
|
||||
<!-- Bad — stale value during render; extra reactive cycle; unclear intent -->
|
||||
<script>
|
||||
let fullName = $state('');
|
||||
$effect(() => {
|
||||
fullName = `${person.firstName} ${person.lastName}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Good — synchronous, single-pass, intent is obvious -->
|
||||
<script>
|
||||
const fullName = $derived(`${person.firstName} ${person.lastName}`);
|
||||
</script>
|
||||
```
|
||||
|
||||
Use `$derived.by(() => { ... })` when the computation needs multiple statements.
|
||||
|
||||
### Use Svelte reactive collections, not plain JS ones
|
||||
|
||||
Svelte 5's reactivity tracks object *references*, not mutations. When you call `.set()` on a plain `Map` or `.set()` on a plain `URLSearchParams`, the reference doesn't change — Svelte never notices, and the UI goes silently stale.
|
||||
|
||||
`SvelteMap`, `SvelteSet`, and `SvelteURLSearchParams` from `svelte/reactivity` wrap the native classes and hook into Svelte's dependency tracker. Every mutation notifies the reactive graph; every read registers a dependency.
|
||||
|
||||
```svelte
|
||||
<!-- Bad — mutations are invisible to Svelte; derived values never update -->
|
||||
<script>
|
||||
const freq = new Map<string, number>();
|
||||
freq.set('key', 1); // Svelte does not see this
|
||||
</script>
|
||||
|
||||
<!-- Good — mutations are tracked; all dependents re-run correctly -->
|
||||
<script>
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
const freq = new SvelteMap<string, number>();
|
||||
freq.set('key', 1); // Svelte tracks this
|
||||
</script>
|
||||
```
|
||||
|
||||
The same applies to `URLSearchParams` in reactive contexts — use `SvelteURLSearchParams`.
|
||||
151
COLLABORATING.md
Normal file
151
COLLABORATING.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Collaboration Rules
|
||||
|
||||
How we work together on this project.
|
||||
|
||||
## Honesty and Objectivity
|
||||
|
||||
Evaluate all suggestions on their technical merits. No sycophancy — if something doesn't make sense, doesn't align with best practices, or could be improved, say so directly and constructively. Technical accuracy and project quality take precedence over being agreeable.
|
||||
|
||||
## Core Workflow: Research → Plan → Implement → Validate
|
||||
|
||||
Every non-trivial feature or bug fix follows this sequence:
|
||||
|
||||
1. **Research** — Read the relevant code. Understand existing patterns before touching anything.
|
||||
2. **Plan** — Write a plan to `/.agent/current-plan.md` and align with the user before writing code. Update the plan as work progresses.
|
||||
3. **Implement** — Use Red/Green TDD (see below).
|
||||
4. **Validate** — Run formatters, linters, and tests after every implementation step.
|
||||
|
||||
Never start writing code without having read the relevant files first.
|
||||
|
||||
## Red/Green TDD
|
||||
|
||||
All new behavior is driven by tests written **before** the implementation. The cycle is:
|
||||
|
||||
1. **Red** — Write a test that captures the requirement. Run it and confirm it fails. A test that passes before the implementation is written is not testing anything real.
|
||||
2. **Green** — Write the minimum production code needed to make the test pass. No more.
|
||||
3. **Refactor** — Clean up the implementation (names, structure, duplication) while keeping the test green.
|
||||
4. **Commit** — The test and implementation ship together in a single logical commit.
|
||||
|
||||
Repeat for each new behavior.
|
||||
|
||||
### What level of test to write
|
||||
|
||||
| Scenario | Test type |
|
||||
|---|---|
|
||||
| Business logic, calculations, service rules | Unit test (`DocumentServiceTest`, etc.) |
|
||||
| HTTP contract, request validation, error codes | Controller slice test (`@WebMvcTest`) |
|
||||
| Full user-facing behavior, navigation, forms | E2E Playwright spec |
|
||||
|
||||
### Rules
|
||||
|
||||
- Never write production code without a failing test that requires it.
|
||||
- Keep the Green step minimal — resist adding "obvious" extras that have no test yet.
|
||||
- The Refactor step must not change behavior — if a test breaks, the refactor introduced a bug.
|
||||
- If a bug is reported with no test, write the failing test first, then fix it.
|
||||
|
||||
## Issue Tracking (Gitea)
|
||||
|
||||
All work is tracked in **Gitea** at `http://192.168.178.71:3005` (repo `marcel/familienarchiv`). Never use todo files or CLAUDE.md notes as a substitute.
|
||||
|
||||
Create an issue whenever work is identified that isn't being done in the current session.
|
||||
|
||||
### Issue title formats
|
||||
|
||||
**`feature` label** — user story format:
|
||||
```
|
||||
As a [role] I want [capability] so/because [reason]
|
||||
```
|
||||
Examples:
|
||||
- "As a user I want to search documents so I can find a specific document faster"
|
||||
- "As an admin I want to add a new user so I don't have to restart the server"
|
||||
|
||||
**`bug` label** — user-facing impact, not the technical cause:
|
||||
```
|
||||
[What breaks] when [trigger]
|
||||
```
|
||||
Examples:
|
||||
- "Document list shows blank page when no results found"
|
||||
- "Upload fails silently when file exceeds 50MB"
|
||||
|
||||
**`devops` label** — infrastructure, CI/CD, deployment, tooling:
|
||||
- "Fix CI checkout failing due to unresolvable hostname"
|
||||
- "Add E2E test seed data for runner"
|
||||
|
||||
### Priority labels
|
||||
|
||||
- `priority: high` — blocking or urgent
|
||||
- `priority: medium` — normal
|
||||
- `priority: low` — nice to have
|
||||
|
||||
### Other labels
|
||||
|
||||
- `needs-discussion` — decision needed before work starts
|
||||
- `wontfix` — acknowledged, not addressing
|
||||
|
||||
## Branching and Pull Requests
|
||||
|
||||
All changes go through a branch and a pull request — never commit directly to `main`.
|
||||
|
||||
### Branch naming
|
||||
|
||||
```
|
||||
<type>/<short-description>
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `feat/received-documents-person-page`
|
||||
- `fix/tag-filter-url-sync`
|
||||
- `devops/docker-compose-v2`
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Create a branch from `main` before writing any code.
|
||||
2. Commit work to that branch.
|
||||
3. Open a pull request on Gitea targeting `main` for review.
|
||||
4. Wait for approval before merging.
|
||||
|
||||
The PR description should reference the related issue (`Closes #n` or `Refs #n`).
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Every commit must reference the relevant Gitea issue.
|
||||
|
||||
- `Closes #12` — commit fully resolves the issue (Gitea auto-closes it)
|
||||
- `Refs #12` — commit is related but doesn't fully close the issue
|
||||
|
||||
Place the reference at the end of the commit body:
|
||||
|
||||
```
|
||||
feat: add person typeahead to document edit form
|
||||
|
||||
Closes #7
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
### Atomic commits
|
||||
|
||||
Each commit must do exactly one logical thing. Never bundle multiple unrelated changes into a single commit, even if they are small.
|
||||
|
||||
**Wrong** — three changes in one commit:
|
||||
```
|
||||
fix(e2e+i18n): add missing DE translation, fix test selectors, fix lang switching
|
||||
```
|
||||
|
||||
**Right** — three separate commits:
|
||||
```
|
||||
fix(i18n): add missing person_btn_conversations DE translation
|
||||
fix(e2e): exclude /persons/new from person link selector
|
||||
fix(e2e): clear locale cookie when switching back to base language
|
||||
```
|
||||
|
||||
When in doubt, commit more often rather than less.
|
||||
|
||||
## Code Style
|
||||
|
||||
See [CODESTYLE.md](./CODESTYLE.md) for the full guide: Clean Code (Uncle Bob), DRY/KISS trade-offs, and SOLID principles applied to this stack.
|
||||
|
||||
Quick reminders:
|
||||
- Pure functions over stateful helpers where possible
|
||||
- No premature abstractions — KISS beats DRY
|
||||
- No backwards-compatibility shims for code that has no callers
|
||||
- Validate at system boundaries only (user input, external APIs)
|
||||
3
backend/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
3
backend/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip
|
||||
@@ -1,8 +1,9 @@
|
||||
# Wir nutzen Java 21 (LTS), da Spring Boot 3 das empfiehlt
|
||||
FROM mcr.microsoft.com/devcontainers/java:1-21-bullseye
|
||||
FROM eclipse-temurin:21-jdk
|
||||
|
||||
# Optional: Zusätzliche OS-Pakete installieren
|
||||
# RUN apt-get update && apt-get install -y <package-name>
|
||||
WORKDIR /app
|
||||
|
||||
# Port für Spring Boot
|
||||
EXPOSE 8080
|
||||
|
||||
# Source code and mvnw are mounted via docker-compose volume at runtime.
|
||||
# Maven dependencies are cached in a named volume (~/.m2).
|
||||
CMD ["./mvnw", "spring-boot:run"]
|
||||
|
||||
378
backend/mvnw.cmd
vendored
378
backend/mvnw.cmd
vendored
@@ -1,189 +1,189 @@
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
|
||||
@@ -4,13 +4,16 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.model.UserGroup;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.raddatz.familienarchiv.repository.TagRepository;
|
||||
import org.raddatz.familienarchiv.repository.UserGroupRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
@@ -20,15 +23,12 @@ import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@DependsOn("flyway")
|
||||
public class DataInitializer {
|
||||
|
||||
@Value("${app.admin.username:admin}")
|
||||
@@ -67,111 +67,106 @@ public class DataInitializer {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Deterministic seed data for E2E tests.
|
||||
*
|
||||
* Activated only with --spring.profiles.active=e2e (local and CI).
|
||||
* Idempotent: skips seeding if persons already exist (e.g. on restart).
|
||||
*
|
||||
* Persons, tags, and documents are hardcoded so E2E assertions are stable
|
||||
* across every run — no random names or dates.
|
||||
*/
|
||||
@Bean
|
||||
@Profile("dev")
|
||||
public CommandLineRunner initData(PersonRepository personRepo,
|
||||
DocumentRepository docRepo) {
|
||||
@Profile("e2e")
|
||||
public CommandLineRunner initE2EData(PersonRepository personRepo,
|
||||
DocumentRepository docRepo,
|
||||
TagRepository tagRepo) {
|
||||
return args -> {
|
||||
// Nur ausführen, wenn DB leer ist
|
||||
if (personRepo.count() > 0) {
|
||||
log.info("Datenbank enthält bereits Daten. Überspringe Initialisierung.");
|
||||
log.info("E2E seed: Daten bereits vorhanden, überspringe.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Generiere Testdaten...");
|
||||
log.info("E2E seed: Erstelle deterministische Testdaten...");
|
||||
|
||||
// 1. Personen erstellen
|
||||
List<Person> persons = new ArrayList<>();
|
||||
String[] firstNames = { "Hans", "Helga", "Thomas", "Maria", "Otto", "Anna", "Paul", "Lisa" };
|
||||
String[] lastNames = { "Müller", "Schmidt", "Schneider", "Fischer", "Weber", "Meyer" };
|
||||
// ── Persons ──────────────────────────────────────────────────────
|
||||
Person hans = personRepo.save(Person.builder()
|
||||
.firstName("Hans").lastName("Müller").build());
|
||||
Person anna = personRepo.save(Person.builder()
|
||||
.firstName("Anna").lastName("Schmidt").build());
|
||||
Person otto = personRepo.save(Person.builder()
|
||||
.firstName("Otto").lastName("Fischer").build());
|
||||
Person maria = personRepo.save(Person.builder()
|
||||
.firstName("Maria").lastName("Weber").build());
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
String fn = firstNames[ThreadLocalRandom.current().nextInt(firstNames.length)];
|
||||
String ln = lastNames[ThreadLocalRandom.current().nextInt(lastNames.length)];
|
||||
// ── Tags ─────────────────────────────────────────────────────────
|
||||
Tag tagFamilie = tagRepo.save(Tag.builder().name("Familie").build());
|
||||
Tag tagKrieg = tagRepo.save(Tag.builder().name("Krieg").build());
|
||||
Tag tagUrlaub = tagRepo.save(Tag.builder().name("Urlaub").build());
|
||||
|
||||
persons.add(personRepo.save(Person.builder()
|
||||
.firstName(fn)
|
||||
.lastName(ln)
|
||||
.alias(i % 5 == 0 ? "Alias " + i : null)
|
||||
.build()));
|
||||
}
|
||||
// Speichern (falls nicht im Loop geschehen, aber save returns entity)
|
||||
// Hier nutzen wir die return values aus dem Loop, da save() die ID setzt.
|
||||
// ── Documents ────────────────────────────────────────────────────
|
||||
// 1. Fully transcribed letter — used by search + detail E2E tests
|
||||
docRepo.save(Document.builder()
|
||||
.title("Geburtsurkunde Hans Müller")
|
||||
.originalFilename("geburtsurkunde_hans.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.documentDate(LocalDate.of(1923, 4, 12))
|
||||
.location("Berlin")
|
||||
.sender(hans)
|
||||
.receivers(Set.of(anna))
|
||||
.tags(Set.of(tagFamilie))
|
||||
.transcription("Hiermit wird beurkundet, dass Hans Müller am 12. April 1923 in Berlin geboren wurde.")
|
||||
.build());
|
||||
|
||||
// 2. Dokumente erstellen
|
||||
List<Document> documents = new ArrayList<>();
|
||||
String[] cities = { "Berlin", "München", "Hamburg", "Köln" };
|
||||
// 2. Letter with multiple receivers and tags — tests multi-receiver display
|
||||
docRepo.save(Document.builder()
|
||||
.title("Brief aus dem Krieg")
|
||||
.originalFilename("brief_krieg_1944.pdf")
|
||||
.status(DocumentStatus.TRANSCRIBED)
|
||||
.documentDate(LocalDate.of(1944, 6, 6))
|
||||
.location("Normandie")
|
||||
.sender(otto)
|
||||
.receivers(Set.of(anna, maria))
|
||||
.tags(Set.of(tagKrieg, tagFamilie))
|
||||
.transcription("Liebe Anna, ich schreibe dir aus der Front. Es geht mir den Umständen entsprechend gut.")
|
||||
.build());
|
||||
|
||||
for (int i = 0; i < 500; i++) {
|
||||
Person sender = persons.get(ThreadLocalRandom.current().nextInt(persons.size()));
|
||||
// 3. Postcard — no transcription, tests PLACEHOLDER status
|
||||
docRepo.save(Document.builder()
|
||||
.title("Urlaubspostkarte Ostsee")
|
||||
.originalFilename("postkarte_1965.jpg")
|
||||
.status(DocumentStatus.PLACEHOLDER)
|
||||
.documentDate(LocalDate.of(1965, 8, 3))
|
||||
.location("Rügen")
|
||||
.sender(anna)
|
||||
.receivers(Set.of(hans))
|
||||
.tags(Set.of(tagUrlaub))
|
||||
.build());
|
||||
|
||||
// Zufällige Empfänger (0 bis 3)
|
||||
Set<Person> receivers = new HashSet<>();
|
||||
int numReceivers = ThreadLocalRandom.current().nextInt(4);
|
||||
for (int j = 0; j < numReceivers; j++) {
|
||||
receivers.add(persons.get(ThreadLocalRandom.current().nextInt(persons.size())));
|
||||
}
|
||||
// 4. Document with no sender — tests null-sender display ("Unbekannt")
|
||||
docRepo.save(Document.builder()
|
||||
.title("Unbekanntes Dokument")
|
||||
.originalFilename("unbekannt.pdf")
|
||||
.status(DocumentStatus.PLACEHOLDER)
|
||||
.documentDate(LocalDate.of(1950, 1, 1))
|
||||
.location("München")
|
||||
.receivers(Set.of(maria))
|
||||
.build());
|
||||
|
||||
Document doc = Document.builder()
|
||||
.title("Dokument " + i)
|
||||
.originalFilename("scan_" + i + ".pdf")
|
||||
.status(i%2 == 0? DocumentStatus.TRANSCRIBED: DocumentStatus.PLACEHOLDER)
|
||||
.documentDate(LocalDate.now().minusDays(ThreadLocalRandom.current().nextInt(365 * 50))) // Bis
|
||||
// zu 50
|
||||
// Jahre
|
||||
// alt
|
||||
.location(cities[ThreadLocalRandom.current().nextInt(cities.length)])
|
||||
.transcription(i%2 == 0? LOREM_IPSUM_LANG: null)
|
||||
.sender(sender)
|
||||
.receivers(receivers)
|
||||
.build();
|
||||
// 5. Document with minimal metadata — tests sparse display
|
||||
docRepo.save(Document.builder()
|
||||
.title("Scan ohne Titel")
|
||||
.originalFilename("scan_ohne_titel.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.documentDate(LocalDate.of(1978, 11, 20))
|
||||
.location("Hamburg")
|
||||
.sender(maria)
|
||||
.receivers(Set.of(otto))
|
||||
.build());
|
||||
|
||||
documents.add(doc);
|
||||
}
|
||||
|
||||
// Batch Save ist performanter
|
||||
docRepo.saveAll(documents);
|
||||
|
||||
log.info("Initialisierung abgeschlossen: 4 Personen und 500 Dokumente erstellt.");
|
||||
log.info("E2E seed: {} Personen, {} Tags, {} Dokumente erstellt.",
|
||||
personRepo.count(), tagRepo.count(), docRepo.count());
|
||||
};
|
||||
}
|
||||
private final String LOREM_IPSUM_LANG="Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. \n" + //
|
||||
"\n" + //
|
||||
"Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. \n" + //
|
||||
"\n" + //
|
||||
"Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. \n" + //
|
||||
"\n" + //
|
||||
"Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. \n" + //
|
||||
"\n" + //
|
||||
"Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis. \n" + //
|
||||
"\n" + //
|
||||
"At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, At accusam aliquyam diam diam dolore dolores duo eirmod eos erat, et nonumy sed tempor et et invidunt justo labore Stet clita ea et gubergren, kasd magna no rebum. sanctus sea sed takimata ut vero voluptua. est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat. \n" + //
|
||||
"\n" + //
|
||||
"Consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus. \n" + //
|
||||
"\n" + //
|
||||
"Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. \n" + //
|
||||
"\n" + //
|
||||
"Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. \n" + //
|
||||
"\n" + //
|
||||
"Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. \n" + //
|
||||
"\n" + //
|
||||
"Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. \n" + //
|
||||
"\n" + //
|
||||
"Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis. \n" + //
|
||||
"\n" + //
|
||||
"At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, At accusam aliquyam diam diam dolore dolores duo eirmod eos erat, et nonumy sed tempor et et invidunt justo labore Stet clita ea et gubergren, kasd magna no rebum. sanctus sea sed takimata ut vero voluptua. est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat. \n" + //
|
||||
"\n" + //
|
||||
"Consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus. \n" + //
|
||||
"\n" + //
|
||||
"Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. \n" + //
|
||||
"\n" + //
|
||||
"Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. \n" + //
|
||||
"\n" + //
|
||||
"Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. \n" + //
|
||||
"\n" + //
|
||||
"Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. \n" + //
|
||||
"\n" + //
|
||||
"Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis. \n" + //
|
||||
"\n" + //
|
||||
"At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, At accusam aliquyam diam diam dolore dolores duo eirmod eos erat, et nonumy sed tempor et et invidunt justo labore Stet clita ea et gubergren, kasd magna no rebum. sanctus sea sed takimata ut vero voluptua. est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat. ";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.raddatz.familienarchiv.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FlywayConfig {
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
@Bean(name = "flyway")
|
||||
public Flyway flyway() {
|
||||
log.info("Running Flyway migrations...");
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration")
|
||||
.baselineOnMigrate(true)
|
||||
.baselineVersion("4")
|
||||
.load();
|
||||
var result = flyway.migrate();
|
||||
log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted);
|
||||
return flyway;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ public class SecurityConfig {
|
||||
.csrf(csrf -> csrf.disable())
|
||||
|
||||
.authorizeHttpRequests(auth -> {
|
||||
// Health endpoint must be open so CI/Docker health checks work without credentials
|
||||
auth.requestMatchers("/actuator/health").permitAll();
|
||||
// In dev, allow unauthenticated access to the OpenAPI spec and Swagger UI
|
||||
if (environment.matchesProfiles("dev")) {
|
||||
auth.requestMatchers(
|
||||
|
||||
@@ -9,16 +9,15 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.FileService;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -39,26 +38,21 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@Slf4j
|
||||
public class DocumentController {
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final DocumentService documentService;
|
||||
private final FileService fileService;
|
||||
|
||||
// --- DOWNLOAD ---
|
||||
@GetMapping("/{id}/file")
|
||||
public ResponseEntity<InputStreamResource> getDocumentFile(@PathVariable UUID id) {
|
||||
// 1. Look up path in DB
|
||||
Document doc = documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
Document doc = documentService.getDocumentById(id);
|
||||
|
||||
if (doc.getFilePath() == null) {
|
||||
throw DomainException.notFound(ErrorCode.DOCUMENT_NO_FILE, "Document has no file attached: " + id);
|
||||
}
|
||||
|
||||
// 2. Delegate Retrieval to FileService
|
||||
try {
|
||||
FileService.S3FileDownload download = fileService.downloadFile(doc.getFilePath());
|
||||
|
||||
// Prefer the content type stored at upload time; fall back to whatever S3 reports
|
||||
String contentType = (doc.getContentType() != null && !doc.getContentType().isBlank())
|
||||
? doc.getContentType()
|
||||
: download.contentType();
|
||||
@@ -75,8 +69,7 @@ public class DocumentController {
|
||||
// --- METADATA ---
|
||||
@GetMapping("/{id}")
|
||||
public Document getDocument(@PathVariable UUID id) {
|
||||
return documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
return documentService.getDocumentById(id);
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -95,7 +88,7 @@ public class DocumentController {
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public Document updateDocument(
|
||||
@PathVariable UUID id,
|
||||
@ModelAttribute DocumentUpdateDTO dto, // Bindet Form-Felder automatisch
|
||||
@ModelAttribute DocumentUpdateDTO dto,
|
||||
@RequestPart(value = "file", required = false) MultipartFile file) {
|
||||
try {
|
||||
return documentService.updateDocument(id, dto, file);
|
||||
@@ -121,18 +114,8 @@ public class DocumentController {
|
||||
@RequestParam UUID receiverId,
|
||||
@RequestParam(required = false) LocalDate from,
|
||||
@RequestParam(required = false) LocalDate to,
|
||||
@RequestParam(defaultValue = "DESC") String dir // ASC oder DESC
|
||||
) {
|
||||
// 1. Standard-Datumswerte setzen
|
||||
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
|
||||
LocalDate dateTo = (to != null) ? to : LocalDate.now();
|
||||
|
||||
// 2. Sortierung
|
||||
Sort.Direction direction = Sort.Direction.fromString(dir.toUpperCase());
|
||||
Sort sort = Sort.by(direction, "documentDate");
|
||||
|
||||
// 3. Abfrage
|
||||
return documentRepository.findConversation(
|
||||
senderId, receiverId, dateFrom, dateTo, sort);
|
||||
@RequestParam(defaultValue = "DESC") String dir) {
|
||||
Sort sort = Sort.by(Sort.Direction.fromString(dir.toUpperCase()), "documentDate");
|
||||
return documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,9 @@ import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.GroupDTO;
|
||||
import org.raddatz.familienarchiv.model.UserGroup;
|
||||
import org.raddatz.familienarchiv.repository.UserGroupRepository;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -28,33 +25,22 @@ import lombok.RequiredArgsConstructor;
|
||||
@RequirePermission(Permission.ADMIN_PERMISSION)
|
||||
@RequiredArgsConstructor
|
||||
public class GroupController {
|
||||
private final UserGroupRepository groupRepository;
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
@PostMapping("")
|
||||
public ResponseEntity<UserGroup> createGroup(@RequestBody GroupDTO dto) {
|
||||
UserGroup group = new UserGroup();
|
||||
group.setName(dto.getName());
|
||||
group.setPermissions(dto.getPermissions()); // Assuming entity has Set<String> or Set<Enum>
|
||||
return ResponseEntity.ok(groupRepository.save(group));
|
||||
return ResponseEntity.ok(userService.createGroup(dto));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
public ResponseEntity<UserGroup> updateGroup(@PathVariable UUID id, @RequestBody GroupDTO dto) {
|
||||
UserGroup group = groupRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.INTERNAL_ERROR, "Group not found: " + id));
|
||||
|
||||
if (dto.getName() != null)
|
||||
group.setName(dto.getName());
|
||||
if (dto.getPermissions() != null)
|
||||
group.setPermissions(dto.getPermissions());
|
||||
|
||||
return ResponseEntity.ok(groupRepository.save(group));
|
||||
return ResponseEntity.ok(userService.updateGroup(id, dto));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteGroup(@PathVariable UUID id) {
|
||||
groupRepository.deleteById(id);
|
||||
userService.deleteGroup(id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@@ -62,5 +48,4 @@ public class GroupController {
|
||||
public ResponseEntity<List<UserGroup>> getAllGroups() {
|
||||
return ResponseEntity.ok(userService.getAllGroups());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,57 +1,75 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.PersonService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/persons")
|
||||
@RequiredArgsConstructor
|
||||
public class PersonController {
|
||||
|
||||
private final PersonRepository personRepository;
|
||||
private final DocumentRepository documentRepository;
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<Person>> getPersons(@RequestParam(required = false) String q) {
|
||||
if (q != null && !q.isBlank()) {
|
||||
return ResponseEntity.ok(personRepository.searchByName(q));
|
||||
}
|
||||
return ResponseEntity.ok(personRepository.findAllByOrderByLastNameAscFirstNameAsc());
|
||||
return ResponseEntity.ok(personService.findAll(q));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Person getPerson(@PathVariable UUID id) {
|
||||
return personRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
|
||||
return personService.getById(id);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/correspondents")
|
||||
public ResponseEntity<List<Person>> getCorrespondents(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam(required = false) String q) {
|
||||
return ResponseEntity.ok(personService.findCorrespondents(id, q));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/documents")
|
||||
public List<Document> getPersonDocuments(@PathVariable UUID id) {
|
||||
return documentRepository.findBySenderId(id);
|
||||
return documentService.getDocumentsBySender(id);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody Map<String, String> body) {
|
||||
@GetMapping("/{id}/received-documents")
|
||||
public List<Document> getPersonReceivedDocuments(@PathVariable UUID id) {
|
||||
return documentService.getDocumentsByReceiver(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Person> createPerson(@RequestBody Map<String, String> body) {
|
||||
String firstName = body.get("firstName");
|
||||
String lastName = body.get("lastName");
|
||||
if (firstName == null || firstName.isBlank() || lastName == null || lastName.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
||||
}
|
||||
return ResponseEntity.ok(personService.updatePerson(id, firstName.trim(), lastName.trim(), body.get("alias")));
|
||||
return ResponseEntity.ok(personService.createPerson(firstName.trim(), lastName.trim(), body.get("alias")));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody PersonUpdateDTO dto) {
|
||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
||||
}
|
||||
dto.setFirstName(dto.getFirstName().trim());
|
||||
dto.setLastName(dto.getLastName().trim());
|
||||
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/merge")
|
||||
|
||||
@@ -4,14 +4,12 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.repository.TagRepository;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.TagService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -21,46 +19,31 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import jakarta.transaction.Transactional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tags")
|
||||
@RequiredArgsConstructor
|
||||
public class TagController {
|
||||
private final TagRepository tagRepository;
|
||||
private final DocumentRepository documentRepository;
|
||||
|
||||
// Rename Tag
|
||||
private final TagService tagService;
|
||||
private final DocumentService documentService;
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@RequirePermission(Permission.ADMIN_TAG)
|
||||
public ResponseEntity<Tag> updateTag(@PathVariable UUID id, @RequestBody Map<String, String> payload) {
|
||||
Tag tag = tagRepository.findById(id).orElseThrow();
|
||||
tag.setName(payload.get("name"));
|
||||
return ResponseEntity.ok(tagRepository.save(tag));
|
||||
return ResponseEntity.ok(tagService.update(id, payload.get("name")));
|
||||
}
|
||||
|
||||
// Delete Tag
|
||||
@DeleteMapping("/{id}")
|
||||
@RequirePermission(Permission.ADMIN_TAG)
|
||||
@Transactional
|
||||
public ResponseEntity<Void> deleteTag(@PathVariable UUID id) {
|
||||
Tag tag = tagRepository.findById(id).orElseThrow();
|
||||
|
||||
// Remove tag from all documents first to prevent FK constraint errors
|
||||
List<Document> documents = documentRepository.findByTags_Id(id);
|
||||
for (Document doc : documents) {
|
||||
doc.getTags().remove(tag);
|
||||
documentRepository.save(doc);
|
||||
}
|
||||
|
||||
tagRepository.delete(tag);
|
||||
documentService.deleteTagCascading(id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Tag> searchTags(@RequestParam(defaultValue = "") String query) {
|
||||
return tagRepository.findByNameContainingIgnoreCase(query);
|
||||
return tagService.search(query);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
@@ -16,8 +18,10 @@ import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -33,13 +37,32 @@ public class UserController {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
|
||||
// Fetch full user object from DB to get latest permissions/groups
|
||||
AppUser user = userService.findByUsername(authentication.getName());
|
||||
|
||||
// Security: Remove password before sending
|
||||
user.setPassword(null);
|
||||
return ResponseEntity.ok(user);
|
||||
}
|
||||
|
||||
@PutMapping("users/me")
|
||||
public ResponseEntity<AppUser> updateProfile(Authentication authentication,
|
||||
@RequestBody UpdateProfileDTO dto) {
|
||||
AppUser current = userService.findByUsername(authentication.getName());
|
||||
AppUser updated = userService.updateProfile(current.getId(), dto);
|
||||
updated.setPassword(null);
|
||||
return ResponseEntity.ok(updated);
|
||||
}
|
||||
|
||||
@PostMapping("users/me/password")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void changePassword(Authentication authentication,
|
||||
@RequestBody ChangePasswordDTO dto) {
|
||||
AppUser current = userService.findByUsername(authentication.getName());
|
||||
userService.changePassword(current.getId(), dto);
|
||||
}
|
||||
|
||||
@GetMapping("users/{id}")
|
||||
public ResponseEntity<AppUser> getUser(@PathVariable UUID id) {
|
||||
AppUser user = userService.getById(id);
|
||||
user.setPassword(null);
|
||||
return ResponseEntity.ok(user);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ChangePasswordDTO {
|
||||
private String currentPassword;
|
||||
private String newPassword;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PersonUpdateDTO {
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String alias;
|
||||
private String notes;
|
||||
private Integer birthYear;
|
||||
private Integer deathYear;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
public class UpdateProfileDTO {
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private LocalDate birthDate;
|
||||
private String email;
|
||||
private String contact;
|
||||
}
|
||||
@@ -43,6 +43,10 @@ public class DomainException extends RuntimeException {
|
||||
return new DomainException(code, HttpStatus.CONFLICT, message);
|
||||
}
|
||||
|
||||
public static DomainException badRequest(ErrorCode code, String message) {
|
||||
return new DomainException(code, HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
public static DomainException internal(ErrorCode code, String message) {
|
||||
return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ public enum ErrorCode {
|
||||
// --- Users ---
|
||||
/** A user with the given ID or username does not exist. 404 */
|
||||
USER_NOT_FOUND,
|
||||
/** The supplied email address is already used by another account. 409 */
|
||||
EMAIL_ALREADY_IN_USE,
|
||||
/** The supplied current password does not match the stored hash. 400 */
|
||||
WRONG_CURRENT_PASSWORD,
|
||||
|
||||
// --- Import ---
|
||||
/** A mass import is already in progress; only one can run at a time. 409 */
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
@@ -36,8 +37,16 @@ public class AppUser {
|
||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
private String password; // Wird verschlüsselt gespeichert (BCrypt)
|
||||
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private LocalDate birthDate;
|
||||
|
||||
@Column(unique = true)
|
||||
private String email;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String contact;
|
||||
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private boolean enabled = true; // Um User zu sperren ohne sie zu löschen
|
||||
|
||||
@@ -28,4 +28,11 @@ public class Person {
|
||||
|
||||
// Optional: Aliasse für die Suche (z.B. "Opa Hans")
|
||||
private String alias;
|
||||
|
||||
// Optional: Free-text biographical notes
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String notes;
|
||||
|
||||
private Integer birthYear;
|
||||
private Integer deathYear;
|
||||
}
|
||||
@@ -11,4 +11,5 @@ import java.util.UUID;
|
||||
@Repository
|
||||
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
|
||||
Optional<AppUser> findByUsername(String username);
|
||||
Optional<AppUser> findByEmail(String email);
|
||||
}
|
||||
@@ -30,6 +30,8 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
|
||||
List<Document> findBySenderId(UUID senderId);
|
||||
|
||||
List<Document> findByReceiversId(UUID receiverId);
|
||||
|
||||
List<Document> findByTags_Id(UUID tagId);
|
||||
|
||||
@Query("SELECT DISTINCT d FROM Document d " +
|
||||
|
||||
@@ -28,6 +28,51 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
// Lookup by full alias string, used during ODS mass import
|
||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||
|
||||
// --- Correspondent queries ---
|
||||
|
||||
@Query(value = """
|
||||
SELECT p.* FROM persons p
|
||||
INNER JOIN (
|
||||
SELECT dr.person_id AS other_id, d.id AS doc_id
|
||||
FROM documents d
|
||||
JOIN document_receivers dr ON dr.document_id = d.id
|
||||
WHERE d.sender_id = :personId
|
||||
UNION ALL
|
||||
SELECT d.sender_id AS other_id, d.id AS doc_id
|
||||
FROM documents d
|
||||
JOIN document_receivers dr ON dr.document_id = d.id
|
||||
WHERE dr.person_id = :personId AND d.sender_id IS NOT NULL
|
||||
) shared ON shared.other_id = p.id
|
||||
WHERE p.id != :personId
|
||||
GROUP BY p.id
|
||||
ORDER BY COUNT(DISTINCT shared.doc_id) DESC
|
||||
LIMIT 10
|
||||
""", nativeQuery = true)
|
||||
List<Person> findCorrespondents(@Param("personId") UUID personId);
|
||||
|
||||
@Query(value = """
|
||||
SELECT p.* FROM persons p
|
||||
INNER JOIN (
|
||||
SELECT dr.person_id AS other_id, d.id AS doc_id
|
||||
FROM documents d
|
||||
JOIN document_receivers dr ON dr.document_id = d.id
|
||||
WHERE d.sender_id = :personId
|
||||
UNION ALL
|
||||
SELECT d.sender_id AS other_id, d.id AS doc_id
|
||||
FROM documents d
|
||||
JOIN document_receivers dr ON dr.document_id = d.id
|
||||
WHERE dr.person_id = :personId AND d.sender_id IS NOT NULL
|
||||
) shared ON shared.other_id = p.id
|
||||
WHERE p.id != :personId
|
||||
AND (LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:q,'%'))
|
||||
OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:q,'%'))
|
||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:q,'%')))
|
||||
GROUP BY p.id
|
||||
ORDER BY COUNT(DISTINCT shared.doc_id) DESC
|
||||
LIMIT 10
|
||||
""", nativeQuery = true)
|
||||
List<Person> findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q);
|
||||
|
||||
// --- Merge helpers (native SQL to bypass JPA entity layer) ---
|
||||
|
||||
@Modifying
|
||||
|
||||
@@ -9,8 +9,6 @@ import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.raddatz.familienarchiv.repository.TagRepository;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
@@ -31,14 +29,14 @@ import java.util.UUID;
|
||||
import static org.raddatz.familienarchiv.repository.DocumentSpecifications.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor // Lombok: Erzeugt Constructor für 'final' Felder (Dependency Injection)
|
||||
@Slf4j // Lombok: Logging
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DocumentService {
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final PersonRepository personRepository;
|
||||
private final PersonService personService;
|
||||
private final FileService fileService;
|
||||
private final TagRepository tagRepository;
|
||||
private final TagService tagService;
|
||||
|
||||
/**
|
||||
* Lädt eine Datei hoch.
|
||||
@@ -104,17 +102,19 @@ public class DocumentService {
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
}
|
||||
updateDocumentTags(doc.getId(), tags);
|
||||
doc = documentRepository.findById(doc.getId()).orElseThrow();
|
||||
UUID savedId = doc.getId();
|
||||
updateDocumentTags(savedId, tags);
|
||||
doc = documentRepository.findById(savedId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found after save: " + savedId));
|
||||
|
||||
// Sender
|
||||
if (dto.getSenderId() != null) {
|
||||
doc.setSender(personRepository.findById(dto.getSenderId()).orElse(null));
|
||||
doc.setSender(personService.getById(dto.getSenderId()));
|
||||
}
|
||||
|
||||
// Empfänger
|
||||
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
|
||||
doc.setReceivers(new HashSet<>(personRepository.findAllById(dto.getReceiverIds())));
|
||||
doc.setReceivers(new HashSet<>(personService.getAllById(dto.getReceiverIds())));
|
||||
}
|
||||
|
||||
// Datei
|
||||
@@ -153,17 +153,14 @@ public class DocumentService {
|
||||
|
||||
// 2. Sender verknüpfen
|
||||
if (dto.getSenderId() != null) {
|
||||
Person sender = personRepository.findById(dto.getSenderId()).orElse(null);
|
||||
doc.setSender(sender);
|
||||
doc.setSender(personService.getById(dto.getSenderId()));
|
||||
} else {
|
||||
doc.setSender(null);
|
||||
}
|
||||
|
||||
// 3. Empfänger verknüpfen
|
||||
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
|
||||
List<Person> receivers = personRepository.findAllById(dto.getReceiverIds());
|
||||
|
||||
doc.setReceivers(new HashSet<>(receivers));
|
||||
doc.setReceivers(new HashSet<>(personService.getAllById(dto.getReceiverIds())));
|
||||
} else {
|
||||
doc.getReceivers().clear(); // Alle entfernen
|
||||
}
|
||||
@@ -185,7 +182,8 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
||||
Document doc = documentRepository.findById(docId).orElseThrow();
|
||||
Document doc = documentRepository.findById(docId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
|
||||
|
||||
Set<Tag> newTags = new HashSet<>();
|
||||
|
||||
@@ -195,11 +193,7 @@ public class DocumentService {
|
||||
if (cleanName.isEmpty())
|
||||
continue;
|
||||
|
||||
// Find existing or Create new
|
||||
Tag tag = tagRepository.findByNameIgnoreCase(cleanName)
|
||||
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
|
||||
|
||||
newTags.add(tag);
|
||||
newTags.add(tagService.findOrCreate(cleanName));
|
||||
}
|
||||
|
||||
doc.setTags(newTags);
|
||||
@@ -226,12 +220,11 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID reciever, List<String> tags) {
|
||||
log.info("Tags", tags);
|
||||
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags) {
|
||||
Specification<Document> spec = Specification.where(hasText(text))
|
||||
.and(isBetween(from, to))
|
||||
.and(hasSender(sender))
|
||||
.and(hasReceiver(reciever))
|
||||
.and(hasReceiver(receiver))
|
||||
.and(hasTags(tags));
|
||||
|
||||
// Immer sortiert nach Datum
|
||||
@@ -253,4 +246,32 @@ public class DocumentService {
|
||||
|
||||
return documentRepository.findAll(conversation, Sort.by(Sort.Direction.ASC, "documentDate"));
|
||||
}
|
||||
|
||||
public Document getDocumentById(UUID id) {
|
||||
return documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsBySender(UUID senderId) {
|
||||
return documentRepository.findBySenderId(senderId);
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsByReceiver(UUID receiverId) {
|
||||
return documentRepository.findByReceiversId(receiverId);
|
||||
}
|
||||
|
||||
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
|
||||
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
|
||||
LocalDate dateTo = (to != null) ? to : LocalDate.now();
|
||||
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteTagCascading(UUID tagId) {
|
||||
documentRepository.findByTags_Id(tagId).forEach(doc -> {
|
||||
doc.getTags().removeIf(t -> t.getId().equals(tagId));
|
||||
documentRepository.save(doc);
|
||||
});
|
||||
tagService.delete(tagId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.raddatz.familienarchiv.repository.TagRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -57,8 +55,8 @@ public class MassImportService {
|
||||
}
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final PersonRepository personRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final PersonService personService;
|
||||
private final TagService tagService;
|
||||
private final S3Client s3Client;
|
||||
|
||||
@Value("${app.s3.bucket}")
|
||||
@@ -307,8 +305,7 @@ public class MassImportService {
|
||||
|
||||
Tag tag = null;
|
||||
if (!tagRaw.isBlank()) {
|
||||
tag = tagRepository.findByNameIgnoreCase(tagRaw)
|
||||
.orElseGet(() -> tagRepository.save(Tag.builder().name(tagRaw).build()));
|
||||
tag = tagService.findOrCreate(tagRaw);
|
||||
}
|
||||
|
||||
Document doc = existing.orElse(Document.builder()
|
||||
@@ -362,15 +359,7 @@ public class MassImportService {
|
||||
}
|
||||
|
||||
private Person findOrCreatePerson(String rawName) {
|
||||
String alias = rawName.trim();
|
||||
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
|
||||
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
||||
return personRepository.save(Person.builder()
|
||||
.alias(alias)
|
||||
.firstName(split.firstName())
|
||||
.lastName(split.lastName())
|
||||
.build());
|
||||
});
|
||||
return personService.findOrCreateByAlias(rawName);
|
||||
}
|
||||
|
||||
private Optional<File> findFileRecursive(String filename) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -8,7 +11,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -16,13 +19,65 @@ public class PersonService {
|
||||
|
||||
private final PersonRepository personRepository;
|
||||
|
||||
public List<Person> findAll(String q) {
|
||||
if (q != null && !q.isBlank()) {
|
||||
return personRepository.searchByName(q);
|
||||
}
|
||||
return personRepository.findAllByOrderByLastNameAscFirstNameAsc();
|
||||
}
|
||||
|
||||
public Person getById(UUID id) {
|
||||
return personRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
|
||||
}
|
||||
|
||||
public List<Person> findCorrespondents(UUID personId, String q) {
|
||||
if (q != null && !q.isBlank()) {
|
||||
return personRepository.findCorrespondentsWithFilter(personId, q);
|
||||
}
|
||||
return personRepository.findCorrespondents(personId);
|
||||
}
|
||||
|
||||
public List<Person> getAllById(List<UUID> ids) {
|
||||
return personRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person updatePerson(UUID id, String firstName, String lastName, String alias) {
|
||||
public Person findOrCreateByAlias(String rawName) {
|
||||
String alias = rawName.trim();
|
||||
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
|
||||
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
||||
return personRepository.save(Person.builder()
|
||||
.alias(alias)
|
||||
.firstName(split.firstName())
|
||||
.lastName(split.lastName())
|
||||
.build());
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person createPerson(String firstName, String lastName, String alias) {
|
||||
Person person = Person.builder()
|
||||
.firstName(firstName)
|
||||
.lastName(lastName)
|
||||
.alias(alias == null || alias.isBlank() ? null : alias.trim())
|
||||
.build();
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||
if (dto.getBirthYear() != null && dto.getDeathYear() != null && dto.getBirthYear() > dto.getDeathYear()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
|
||||
}
|
||||
Person person = personRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
|
||||
person.setFirstName(firstName);
|
||||
person.setLastName(lastName);
|
||||
person.setAlias(alias == null || alias.isBlank() ? null : alias.trim());
|
||||
person.setFirstName(dto.getFirstName());
|
||||
person.setLastName(dto.getLastName());
|
||||
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||
person.setBirthYear(dto.getBirthYear());
|
||||
person.setDeathYear(dto.getDeathYear());
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.TagRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TagService {
|
||||
|
||||
private final TagRepository tagRepository;
|
||||
|
||||
public List<Tag> search(String query) {
|
||||
return tagRepository.findByNameContainingIgnoreCase(query);
|
||||
}
|
||||
|
||||
public Tag getById(UUID id) {
|
||||
return tagRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Tag nicht gefunden"));
|
||||
}
|
||||
|
||||
public Tag findOrCreate(String name) {
|
||||
String cleanName = name.trim();
|
||||
return tagRepository.findByNameIgnoreCase(cleanName)
|
||||
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Tag update(UUID id, String newName) {
|
||||
Tag tag = getById(id);
|
||||
tag.setName(newName);
|
||||
return tagRepository.save(tag);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(UUID id) {
|
||||
tagRepository.delete(getById(id));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ package org.raddatz.familienarchiv.service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
|
||||
import org.raddatz.familienarchiv.dto.GroupDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
@@ -29,49 +32,84 @@ public class UserService {
|
||||
private final UserGroupRepository groupRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Transactional
|
||||
public AppUser createUserOrUpdate(CreateUserRequest request) {
|
||||
log.info("Versuche neuen User anzulegen: {}", request.getUsername());
|
||||
@Transactional
|
||||
public AppUser createUserOrUpdate(CreateUserRequest request) {
|
||||
log.info("Creating or updating user: {}", request.getUsername());
|
||||
|
||||
Set<UserGroup> groups = new HashSet<>();
|
||||
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
|
||||
List<UserGroup> foundGroups = groupRepository.findAllById(request.getGroupIds());
|
||||
groups.addAll(foundGroups);
|
||||
Set<UserGroup> groups = new HashSet<>();
|
||||
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
|
||||
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
|
||||
}
|
||||
|
||||
Optional<AppUser> existingUser = userRepository.findByUsername(request.getUsername());
|
||||
AppUser user;
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
log.info("User exists, updating: {}", request.getUsername());
|
||||
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||
} else {
|
||||
log.info("Creating new user: {}", request.getUsername());
|
||||
user = AppUser.builder()
|
||||
.username(request.getUsername())
|
||||
.email(request.getEmail())
|
||||
.password(passwordEncoder.encode(request.getInitialPassword()))
|
||||
.groups(groups)
|
||||
.enabled(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
return userRepository.save(user);
|
||||
}
|
||||
log.info("GroupsIds {}", groups.toString());
|
||||
log.info("Groupds in DB {}", groupRepository.findAll().toString());
|
||||
|
||||
Optional<AppUser> dbUser = userRepository.findByUsername(request.getUsername());
|
||||
AppUser user;
|
||||
|
||||
if (dbUser.isPresent()) {
|
||||
log.info("Found user in DB. Will update.");
|
||||
user = dbUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||
} else {
|
||||
log.info("Creating new user.");
|
||||
user = AppUser.builder()
|
||||
.username(request.getUsername())
|
||||
.email(request.getEmail())
|
||||
.password(passwordEncoder.encode(request.getInitialPassword()))
|
||||
.groups(groups)
|
||||
.enabled(true)
|
||||
.build();
|
||||
}
|
||||
log.info("Saving new user {}", user.toString());
|
||||
return userRepository.save(user);
|
||||
}
|
||||
@Transactional
|
||||
public void deleteUser(UUID userId) {
|
||||
log.info("Delete user {}", userId);
|
||||
|
||||
AppUser user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, String.format("No user found for id %s", userId)));
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
||||
userRepository.delete(user);
|
||||
}
|
||||
|
||||
public AppUser getById(UUID id) {
|
||||
return userRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
|
||||
AppUser user = getById(userId);
|
||||
|
||||
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
||||
userRepository.findByEmail(dto.getEmail()).ifPresent(existing -> {
|
||||
if (!existing.getId().equals(userId)) {
|
||||
throw DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE,
|
||||
"E-Mail wird bereits von einem anderen Konto verwendet");
|
||||
}
|
||||
});
|
||||
user.setEmail(dto.getEmail().trim());
|
||||
} else if (dto.getEmail() != null && dto.getEmail().isBlank()) {
|
||||
user.setEmail(null);
|
||||
}
|
||||
|
||||
user.setFirstName(dto.getFirstName());
|
||||
user.setLastName(dto.getLastName());
|
||||
user.setBirthDate(dto.getBirthDate());
|
||||
user.setContact(dto.getContact() == null || dto.getContact().isBlank() ? null : dto.getContact().trim());
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void changePassword(UUID userId, ChangePasswordDTO dto) {
|
||||
AppUser user = getById(userId);
|
||||
if (!passwordEncoder.matches(dto.getCurrentPassword(), user.getPassword())) {
|
||||
throw DomainException.badRequest(ErrorCode.WRONG_CURRENT_PASSWORD,
|
||||
"Das aktuelle Passwort ist falsch");
|
||||
}
|
||||
user.setPassword(passwordEncoder.encode(dto.getNewPassword()));
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
public AppUser findByUsername(String username) {
|
||||
return userRepository.findByUsername(username).orElseThrow(
|
||||
() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, String.format("No user found for username %s", username)));
|
||||
return userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for username: " + username));
|
||||
}
|
||||
|
||||
public List<AppUser> getAllUsers() {
|
||||
@@ -81,4 +119,30 @@ public AppUser createUserOrUpdate(CreateUserRequest request) {
|
||||
public List<UserGroup> getAllGroups() {
|
||||
return groupRepository.findAll();
|
||||
}
|
||||
|
||||
public UserGroup getGroupById(UUID id) {
|
||||
return groupRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.INTERNAL_ERROR, "Group not found: " + id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserGroup createGroup(GroupDTO dto) {
|
||||
UserGroup group = new UserGroup();
|
||||
group.setName(dto.getName());
|
||||
group.setPermissions(dto.getPermissions());
|
||||
return groupRepository.save(group);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserGroup updateGroup(UUID id, GroupDTO dto) {
|
||||
UserGroup group = getGroupById(id);
|
||||
if (dto.getName() != null) group.setName(dto.getName());
|
||||
if (dto.getPermissions() != null) group.setPermissions(dto.getPermissions());
|
||||
return groupRepository.save(group);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteGroup(UUID id) {
|
||||
groupRepository.deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ spring:
|
||||
password: ${SPRING_DATASOURCE_PASSWORD}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
flyway:
|
||||
enabled: false # Managed explicitly via FlywayConfig bean
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE persons ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE persons ADD COLUMN IF NOT EXISTS birth_year INTEGER;
|
||||
ALTER TABLE persons ADD COLUMN IF NOT EXISTS death_year INTEGER;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE users ADD COLUMN first_name VARCHAR(100);
|
||||
ALTER TABLE users ADD COLUMN last_name VARCHAR(100);
|
||||
ALTER TABLE users ADD COLUMN birth_date DATE;
|
||||
ALTER TABLE users ADD COLUMN contact TEXT;
|
||||
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);
|
||||
@@ -1,13 +0,0 @@
|
||||
package org.raddatz.familienarchiv;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class FamilienarchivApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.FileService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(DocumentController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class DocumentControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean DocumentService documentService;
|
||||
@MockitoBean FileService fileService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/documents/search ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void search_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns200_whenAuthenticated() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createDocument_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void createDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createDocument_returns200_whenHasWritePermission() throws Exception {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.title("Test")
|
||||
.originalFilename("test.pdf")
|
||||
.build();
|
||||
when(documentService.createDocument(any(), any())).thenReturn(doc);
|
||||
|
||||
mockMvc.perform(multipart("/api/documents"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── PUT /api/documents/{id} ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateDocument_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void updateDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updateDocument_returns200_whenHasWritePermission() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.title("Updated")
|
||||
.originalFilename("test.pdf")
|
||||
.build();
|
||||
when(documentService.updateDocument(any(), any(), any())).thenReturn(doc);
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/" + id)
|
||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.PersonService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(PersonController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class PersonControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean PersonService personService;
|
||||
@MockitoBean DocumentService documentService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/persons/{id}/received-documents ─────────────────────────────
|
||||
|
||||
@Test
|
||||
void getReceivedDocuments_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/persons/{id}/received-documents", UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getReceivedDocuments_returns200_whenAuthenticated() throws Exception {
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(documentService.getDocumentsByReceiver(personId)).thenReturn(Collections.emptyList());
|
||||
|
||||
mockMvc.perform(get("/api/persons/{id}/received-documents", personId))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.TagService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(TagController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class TagControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean TagService tagService;
|
||||
@MockitoBean DocumentService documentService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/tags ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void searchTags_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/tags"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void searchTags_returns200_whenAuthenticated() throws Exception {
|
||||
when(tagService.search(any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/tags"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── PUT /api/tags/{id} ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateTag_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\": \"New\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void updateTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\": \"New\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN_TAG")
|
||||
void updateTag_returns200_whenHasAdminTagPermission() throws Exception {
|
||||
Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build();
|
||||
when(tagService.update(any(), any())).thenReturn(tag);
|
||||
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\": \"New\"}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── DELETE /api/tags/{id} ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteTag_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN_TAG")
|
||||
void deleteTag_returns200_whenHasAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentServiceTest {
|
||||
|
||||
@Mock DocumentRepository documentRepository;
|
||||
@Mock PersonService personService;
|
||||
@Mock FileService fileService;
|
||||
@Mock TagService tagService;
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── getDocumentById ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getDocumentById_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> documentService.getDocumentById(id))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining(id.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDocumentById_returnsDocument_whenFound() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("Test").build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
|
||||
assertThat(documentService.getDocumentById(id)).isEqualTo(doc);
|
||||
}
|
||||
|
||||
// ─── updateDocument ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateDocument_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> documentService.updateDocument(id, new DocumentUpdateDTO(), null))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteTagCascading_removesTagFromAllDocumentsAndDeletesTag() {
|
||||
UUID tagId = UUID.randomUUID();
|
||||
Tag tag = Tag.builder().id(tagId).name("Familie").build();
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.tags(new HashSet<>(Set.of(tag)))
|
||||
.build();
|
||||
|
||||
when(documentRepository.findByTags_Id(tagId)).thenReturn(List.of(doc));
|
||||
when(documentRepository.save(any())).thenReturn(doc);
|
||||
|
||||
documentService.deleteTagCascading(tagId);
|
||||
|
||||
assertThat(doc.getTags()).isEmpty();
|
||||
verify(tagService).delete(tagId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteTagCascading_worksWhenNoDocumentsHaveTag() {
|
||||
UUID tagId = UUID.randomUUID();
|
||||
when(documentRepository.findByTags_Id(tagId)).thenReturn(List.of());
|
||||
|
||||
documentService.deleteTagCascading(tagId);
|
||||
|
||||
verify(documentRepository, never()).save(any());
|
||||
verify(tagService).delete(tagId);
|
||||
}
|
||||
|
||||
// ─── createPlaceholder ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createPlaceholder_returnsExisting_whenAlreadyExists() {
|
||||
String filename = "scan001.pdf";
|
||||
Document existing = Document.builder().id(UUID.randomUUID()).originalFilename(filename).build();
|
||||
when(documentRepository.existsByOriginalFilename(filename)).thenReturn(true);
|
||||
when(documentRepository.findByOriginalFilename(filename)).thenReturn(Optional.of(existing));
|
||||
|
||||
Document result = documentService.createPlaceholder(filename);
|
||||
|
||||
assertThat(result).isEqualTo(existing);
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPlaceholder_createsNew_whenNotExists() {
|
||||
String filename = "scan002.pdf";
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).originalFilename(filename).build();
|
||||
when(documentRepository.existsByOriginalFilename(filename)).thenReturn(false);
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
Document result = documentService.createPlaceholder(filename);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(documentRepository).save(any());
|
||||
}
|
||||
|
||||
// ─── getDocumentsByReceiver ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getDocumentsByReceiver_returnsDocumentsWherePersonIsReceiver() {
|
||||
UUID receiverId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
|
||||
when(documentRepository.findByReceiversId(receiverId)).thenReturn(List.of(doc));
|
||||
|
||||
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PersonServiceTest {
|
||||
|
||||
@Mock PersonRepository personRepository;
|
||||
@InjectMocks PersonService personService;
|
||||
|
||||
// ─── getById ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getById_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> personService.getById(id))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returnsPerson_whenFound() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
|
||||
assertThat(personService.getById(id)).isEqualTo(person);
|
||||
}
|
||||
|
||||
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_returnsExisting_whenAliasFound() {
|
||||
String alias = "Walter de Gruyter";
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).alias(alias).build();
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.of(existing));
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
|
||||
assertThat(result).isEqualTo(existing);
|
||||
verify(personRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_createsNew_whenAliasNotFound() {
|
||||
String alias = "Clara Cram";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenReturn(saved);
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(personRepository).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_trimsInput() {
|
||||
String alias = " Clara Cram ";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias("Clara Cram").build();
|
||||
when(personRepository.findByAliasIgnoreCase("Clara Cram")).thenReturn(Optional.of(saved));
|
||||
|
||||
personService.findOrCreateByAlias(alias);
|
||||
|
||||
verify(personRepository).findByAliasIgnoreCase("Clara Cram");
|
||||
}
|
||||
|
||||
// ─── updatePerson (notes) ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_persistsNotes() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setNotes("Some notes here.");
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getNotes()).isEqualTo("Some notes here.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_clearsNotes_whenBlank() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").notes("old notes").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setNotes(" ");
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getNotes()).isNull();
|
||||
}
|
||||
|
||||
// ─── updatePerson (birth/death years) ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_persistsBirthAndDeathYear() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(1965);
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getBirthYear()).isEqualTo(1890);
|
||||
assertThat(result.getDeathYear()).isEqualTo(1965);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1970); dto.setDeathYear(1950);
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_allowsSameYear() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1900); dto.setDeathYear(1900);
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getBirthYear()).isEqualTo(1900);
|
||||
assertThat(result.getDeathYear()).isEqualTo(1900);
|
||||
}
|
||||
|
||||
// ─── findCorrespondents ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findCorrespondents_delegatesToRepository_withoutFilter() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Muster").build());
|
||||
when(personRepository.findCorrespondents(personId)).thenReturn(expected);
|
||||
|
||||
assertThat(personService.findCorrespondents(personId, null)).isEqualTo(expected);
|
||||
verify(personRepository).findCorrespondents(personId);
|
||||
verify(personRepository, never()).findCorrespondentsWithFilter(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findCorrespondents_delegatesToRepository_withFilter() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Muster").build());
|
||||
when(personRepository.findCorrespondentsWithFilter(personId, "Anna")).thenReturn(expected);
|
||||
|
||||
assertThat(personService.findCorrespondents(personId, "Anna")).isEqualTo(expected);
|
||||
verify(personRepository).findCorrespondentsWithFilter(personId, "Anna");
|
||||
verify(personRepository, never()).findCorrespondents(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findCorrespondents_delegatesToRepository_withBlankFilter() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(personRepository.findCorrespondents(personId)).thenReturn(List.of());
|
||||
|
||||
personService.findCorrespondents(personId, " ");
|
||||
|
||||
verify(personRepository).findCorrespondents(personId);
|
||||
verify(personRepository, never()).findCorrespondentsWithFilter(any(), any());
|
||||
}
|
||||
|
||||
// ─── mergePersons ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void mergePersons_throwsBadRequest_whenSourceEqualsTarget() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
assertThatThrownBy(() -> personService.mergePersons(id, id))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergePersons_throwsNotFound_whenSourceMissing() {
|
||||
UUID sourceId = UUID.randomUUID();
|
||||
UUID targetId = UUID.randomUUID();
|
||||
when(personRepository.findById(sourceId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergePersons_throwsNotFound_whenTargetMissing() {
|
||||
UUID sourceId = UUID.randomUUID();
|
||||
UUID targetId = UUID.randomUUID();
|
||||
Person source = Person.builder().id(sourceId).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(sourceId)).thenReturn(Optional.of(source));
|
||||
when(personRepository.findById(targetId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergePersons_reassignsDocumentsAndDeletesSource() {
|
||||
UUID sourceId = UUID.randomUUID();
|
||||
UUID targetId = UUID.randomUUID();
|
||||
Person source = Person.builder().id(sourceId).firstName("Anna").lastName("Alt").build();
|
||||
Person target = Person.builder().id(targetId).firstName("Anna").lastName("Neu").build();
|
||||
when(personRepository.findById(sourceId)).thenReturn(Optional.of(source));
|
||||
when(personRepository.findById(targetId)).thenReturn(Optional.of(target));
|
||||
|
||||
personService.mergePersons(sourceId, targetId);
|
||||
|
||||
verify(personRepository).reassignSender(sourceId, targetId);
|
||||
verify(personRepository).insertMissingReceiverReference(sourceId, targetId);
|
||||
verify(personRepository).deleteReceiverReferences(sourceId);
|
||||
verify(personRepository).deleteById(sourceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.TagRepository;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TagServiceTest {
|
||||
|
||||
@Mock TagRepository tagRepository;
|
||||
@InjectMocks TagService tagService;
|
||||
|
||||
// ─── getById ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getById_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(tagRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> tagService.getById(id))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returnsTag_whenFound() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag tag = Tag.builder().id(id).name("Familie").build();
|
||||
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
|
||||
|
||||
assertThat(tagService.getById(id)).isEqualTo(tag);
|
||||
}
|
||||
|
||||
// ─── findOrCreate ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findOrCreate_returnsExisting_whenNameFound() {
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||
when(tagRepository.findByNameIgnoreCase("Familie")).thenReturn(Optional.of(existing));
|
||||
|
||||
Tag result = tagService.findOrCreate("Familie");
|
||||
|
||||
assertThat(result).isEqualTo(existing);
|
||||
verify(tagRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_createsNew_whenNameNotFound() {
|
||||
Tag saved = Tag.builder().id(UUID.randomUUID()).name("Krieg").build();
|
||||
when(tagRepository.findByNameIgnoreCase("Krieg")).thenReturn(Optional.empty());
|
||||
when(tagRepository.save(any())).thenReturn(saved);
|
||||
|
||||
Tag result = tagService.findOrCreate("Krieg");
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(tagRepository).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_trimsWhitespaceBeforeLookup() {
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Urlaub").build();
|
||||
when(tagRepository.findByNameIgnoreCase("Urlaub")).thenReturn(Optional.of(existing));
|
||||
|
||||
tagService.findOrCreate(" Urlaub ");
|
||||
|
||||
verify(tagRepository).findByNameIgnoreCase("Urlaub");
|
||||
}
|
||||
|
||||
// ─── update ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void update_savesNewName() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag tag = Tag.builder().id(id).name("Old").build();
|
||||
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
|
||||
when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Tag result = tagService.update(id, "New");
|
||||
|
||||
assertThat(result.getName()).isEqualTo("New");
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_throwsNotFound_whenTagMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(tagRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> tagService.update(id, "New"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404);
|
||||
}
|
||||
|
||||
// ─── delete ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void delete_callsRepositoryDelete() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag tag = Tag.builder().id(id).name("ToDelete").build();
|
||||
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
|
||||
|
||||
tagService.delete(id);
|
||||
|
||||
verify(tagRepository).delete(tag);
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_throwsNotFound_whenTagMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(tagRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> tagService.delete(id))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.repository.UserGroupRepository;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock AppUserRepository userRepository;
|
||||
@Mock UserGroupRepository groupRepository;
|
||||
@Mock PasswordEncoder passwordEncoder;
|
||||
@InjectMocks UserService userService;
|
||||
|
||||
// ─── findByUsername ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findByUsername_throwsNotFound_whenMissing() {
|
||||
when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> userService.findByUsername("ghost"))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByUsername_returnsUser_whenFound() {
|
||||
AppUser user = AppUser.builder().id(UUID.randomUUID()).username("admin").build();
|
||||
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
|
||||
|
||||
assertThat(userService.findByUsername("admin")).isEqualTo(user);
|
||||
}
|
||||
|
||||
// ─── deleteUser ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteUser_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> userService.deleteUser(id))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUser_deletesUser_whenFound() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("gast").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
|
||||
userService.deleteUser(id);
|
||||
|
||||
verify(userRepository).delete(user);
|
||||
}
|
||||
|
||||
// ─── createUserOrUpdate ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createUserOrUpdate_createsNewUser_whenNotExists() {
|
||||
CreateUserRequest req = new CreateUserRequest();
|
||||
req.setUsername("newuser");
|
||||
req.setEmail("new@example.com");
|
||||
req.setInitialPassword("secret");
|
||||
req.setGroupIds(List.of());
|
||||
|
||||
when(userRepository.findByUsername("newuser")).thenReturn(Optional.empty());
|
||||
when(passwordEncoder.encode("secret")).thenReturn("encoded");
|
||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("newuser").build();
|
||||
when(userRepository.save(any())).thenReturn(saved);
|
||||
|
||||
AppUser result = userService.createUserOrUpdate(req);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(userRepository).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserOrUpdate_updatesExistingUser_whenFound() {
|
||||
CreateUserRequest req = new CreateUserRequest();
|
||||
req.setUsername("existing");
|
||||
req.setEmail("existing@example.com");
|
||||
req.setInitialPassword("newpass");
|
||||
req.setGroupIds(List.of());
|
||||
|
||||
AppUser existing = AppUser.builder().id(UUID.randomUUID()).username("existing").build();
|
||||
when(userRepository.findByUsername("existing")).thenReturn(Optional.of(existing));
|
||||
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
||||
when(userRepository.save(any())).thenReturn(existing);
|
||||
|
||||
userService.createUserOrUpdate(req);
|
||||
|
||||
// save called once with the updated existing user (no new user created)
|
||||
verify(userRepository, times(1)).save(existing);
|
||||
}
|
||||
|
||||
// ─── getById ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getById_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> userService.getById(id))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returnsUser_whenFound() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
|
||||
assertThat(userService.getById(id)).isEqualTo(user);
|
||||
}
|
||||
|
||||
// ─── updateProfile ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateProfile_updatesFields() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByEmail("max@example.com")).thenReturn(Optional.empty());
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||
dto.setFirstName("Max"); dto.setLastName("Müller"); dto.setEmail("max@example.com");
|
||||
AppUser result = userService.updateProfile(id, dto);
|
||||
|
||||
assertThat(result.getFirstName()).isEqualTo("Max");
|
||||
assertThat(result.getLastName()).isEqualTo("Müller");
|
||||
assertThat(result.getEmail()).isEqualTo("max@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfile_throwsConflict_whenEmailTakenByAnotherUser() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID otherId = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").build();
|
||||
AppUser other = AppUser.builder().id(otherId).username("anna").email("taken@example.com").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByEmail("taken@example.com")).thenReturn(Optional.of(other));
|
||||
|
||||
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||
dto.setEmail("taken@example.com");
|
||||
|
||||
assertThatThrownBy(() -> userService.updateProfile(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("E-Mail");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfile_allowsSameEmailForSameUser() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").email("max@example.com").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByEmail("max@example.com")).thenReturn(Optional.of(user));
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||
dto.setEmail("max@example.com");
|
||||
dto.setFirstName("Max");
|
||||
|
||||
assertThat(userService.updateProfile(id, dto).getEmail()).isEqualTo("max@example.com");
|
||||
}
|
||||
|
||||
// ─── changePassword ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void changePassword_throwsBadRequest_whenCurrentPasswordWrong() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").password("hashed").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("wrong", "hashed")).thenReturn(false);
|
||||
|
||||
ChangePasswordDTO dto = new ChangePasswordDTO();
|
||||
dto.setCurrentPassword("wrong"); dto.setNewPassword("newpass");
|
||||
|
||||
assertThatThrownBy(() -> userService.changePassword(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("Passwort");
|
||||
}
|
||||
|
||||
@Test
|
||||
void changePassword_updatesHash_whenCurrentPasswordCorrect() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").password("hashed").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("correct", "hashed")).thenReturn(true);
|
||||
when(passwordEncoder.encode("newpass")).thenReturn("newHash");
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
ChangePasswordDTO dto = new ChangePasswordDTO();
|
||||
dto.setCurrentPassword("correct"); dto.setNewPassword("newpass");
|
||||
userService.changePassword(id, dto);
|
||||
|
||||
verify(userRepository).save(argThat(u -> "newHash".equals(u.getPassword())));
|
||||
}
|
||||
|
||||
// ─── getGroupById ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getGroupById_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(groupRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> userService.getGroupById(id))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
}
|
||||
18
docker-compose.ci.yml
Normal file
18
docker-compose.ci.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
# CI override — replaces host bind mounts with ephemeral named volumes.
|
||||
# Host port bindings are handled via PORT_DB/PORT_MINIO_API env vars in ci.yml
|
||||
# (set to non-standard ports to avoid conflicts with system services on the runner).
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d db minio create-buckets
|
||||
services:
|
||||
db:
|
||||
volumes:
|
||||
- ci_postgres_data:/var/lib/postgresql/data
|
||||
|
||||
minio:
|
||||
volumes:
|
||||
- ci_minio_data:/data
|
||||
|
||||
volumes:
|
||||
ci_postgres_data:
|
||||
ci_minio_data:
|
||||
@@ -64,11 +64,11 @@ services:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: archive-backend
|
||||
command: sleep infinity
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- .:/workspaces/familienarchiv:cached
|
||||
- ./import-data:/import # Mappt den lokalen Ordner "import-data" auf "/import" im Container
|
||||
- ./backend:/app
|
||||
- ./import:/import
|
||||
- maven_cache:/root/.m2
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -78,35 +78,54 @@ services:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
|
||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
# MinIO Konfiguration für Spring Boot (S3)
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_ACCESS_KEY: ${MINIO_ROOT_USER}
|
||||
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
||||
S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS}
|
||||
S3_REGION: us-east-1 # MinIO Standard
|
||||
S3_REGION: us-east-1
|
||||
ports:
|
||||
- "${PORT_BACKEND}:8080"
|
||||
networks:
|
||||
- archive-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q UP || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 60s
|
||||
|
||||
# --- Frontend: SvelteKit ---
|
||||
# Auch hier brauchen wir erst das Dockerfile im frontend Ordner.
|
||||
# frontend:
|
||||
# build: ./frontend
|
||||
# container_name: archive-frontend
|
||||
# restart: unless-stopped
|
||||
# depends_on:
|
||||
# - backend
|
||||
# environment:
|
||||
# # SvelteKit SSR braucht die interne Docker-URL zum Backend
|
||||
# API_BASE_URL: http://backend:8080
|
||||
# # Der Browser braucht die öffentliche URL (falls Client-Side Fetching genutzt wird)
|
||||
# PUBLIC_API_BASE_URL: http://localhost:${PORT_BACKEND}
|
||||
# ports:
|
||||
# - "${PORT_FRONTEND}:3000"
|
||||
# networks:
|
||||
# - archive-net
|
||||
# --- Frontend: SvelteKit (Dev Server) ---
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: archive-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
backend:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
# Keep container's node_modules separate from host to avoid OS binary conflicts
|
||||
- frontend_node_modules:/app/node_modules
|
||||
environment:
|
||||
# SSR calls (server-side) use the internal Docker network
|
||||
API_INTERNAL_URL: http://backend:8080
|
||||
# Vite dev proxy forwards /api from browser to the backend container
|
||||
API_PROXY_TARGET: http://backend:8080
|
||||
ports:
|
||||
- "${PORT_FRONTEND}:5173"
|
||||
networks:
|
||||
- archive-net
|
||||
|
||||
networks:
|
||||
archive-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
frontend_node_modules:
|
||||
maven_cache:
|
||||
|
||||
389
docs/STYLEGUIDE.md
Normal file
389
docs/STYLEGUIDE.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# Familienarchiv — Design Styleguide
|
||||
|
||||
This document defines the visual language for the Familienarchiv frontend. All UI work should follow these conventions to stay consistent with the De Gruyter Brill corporate identity.
|
||||
|
||||
---
|
||||
|
||||
## Brand Identity
|
||||
|
||||
The design is based on the **De Gruyter Brill** brand identity (unveiled at Frankfurt Book Fair 2024). Key characteristics:
|
||||
|
||||
- Clean white backgrounds, high contrast
|
||||
- Strong typographic hierarchy (uppercase labels, serif body text)
|
||||
- Academic publisher aesthetic: authoritative, clear, uncluttered
|
||||
|
||||
---
|
||||
|
||||
## Colors
|
||||
|
||||
Defined in `src/routes/layout.css` as `@theme` variables. All generate Tailwind utilities automatically (`bg-*`, `text-*`, `border-*`).
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| `brand-navy` | `#012851` | Primary text, headings, buttons, active states — Prussian Blue |
|
||||
| `brand-mint` | `#A1DCD8` | Accent color, icon tints, hover underlines — Aqua Island |
|
||||
| `brand-purple` | `#B4B9FF` | Logo, nav active state highlight, top accent strip — Melrose |
|
||||
| `brand-sand` | `#F0EFE9` | Subtle card backgrounds, borders, hover backgrounds — paper tone |
|
||||
| `brand-white` | `#FFFFFF` | Page background, card surfaces |
|
||||
| `brand-dark` | `#0D0D0D` | Near-black text when maximum contrast is needed |
|
||||
|
||||
### Color usage rules
|
||||
|
||||
- **Never** use raw hex values in components — always use token utilities.
|
||||
- Page and card backgrounds are **white**. Use `bg-brand-sand` only for subtle inset areas (e.g. `bg-brand-sand/30`).
|
||||
- `brand-navy` is the workhorse: headings, body text, borders, primary buttons.
|
||||
- `brand-mint` is an accent only — never use it as primary text color on white (contrast too low).
|
||||
- `brand-purple` is reserved for the logo and the single top accent strip in the header.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Fonts
|
||||
|
||||
| Role | Font | Tailwind | Notes |
|
||||
|---|---|---|---|
|
||||
| Body / Serif | **Tinos** (Times substitute) | `font-serif` | Loaded from Google Fonts. Used for document titles, names, body copy, dates. Matches DGB's use of Times. |
|
||||
| UI / Sans | **Montserrat** (Gotham substitute) | `font-sans` | Loaded from Google Fonts. Used for labels, navigation, buttons, metadata, form elements. Matches DGB's use of Gotham. |
|
||||
|
||||
### Type scale and usage
|
||||
|
||||
| Element | Classes | Example |
|
||||
|---|---|---|
|
||||
| Page title | `font-serif text-3xl text-brand-navy` | `<h1>` |
|
||||
| Card section heading | `font-sans text-xs font-bold uppercase tracking-widest text-gray-400` | Section labels |
|
||||
| Document / item title | `font-serif text-xl font-medium text-brand-navy` | List items |
|
||||
| Metadata / label | `font-sans text-xs font-bold uppercase tracking-widest text-gray-500` | Field labels |
|
||||
| Body text | `font-serif text-sm text-brand-navy` | Descriptions, summaries |
|
||||
| Navigation | `font-sans text-xs font-bold uppercase tracking-widest` | Nav links |
|
||||
|
||||
### Rules
|
||||
|
||||
- **Labels are always uppercase + tracked**: `text-xs font-bold uppercase tracking-widest`
|
||||
- **Headings** use `font-sans` (Montserrat), set in CSS globally.
|
||||
- **Content** (document titles, person names, summaries) uses `font-serif` (Tinos).
|
||||
- Never use `font-serif` for UI chrome (buttons, labels, nav).
|
||||
|
||||
---
|
||||
|
||||
## Icons
|
||||
|
||||
### Library
|
||||
|
||||
686 SVG icons in `frontend/static/degruyter-icons/`. Two families:
|
||||
|
||||
- **Simple** — single-color, action-oriented. Use for all UI icons. Available in 4 sizes.
|
||||
- **Complex** — multi-color illustrative icons. Use sparingly for empty states or section headers.
|
||||
|
||||
### Simple icon sizes
|
||||
|
||||
| Size | Pixels | Path segment | Use |
|
||||
|---|---|---|---|
|
||||
| XS | 12px | `X-Small-12px` | Inline text hints, badges |
|
||||
| SM | 16px | `Small-16px` | Compact UI, table cells |
|
||||
| **MD** | **24px** | **`Medium-24px`** | **Standard UI icon — default choice** |
|
||||
| LG | 32px | `Large-32px` | Feature headers, empty states |
|
||||
|
||||
### URL pattern
|
||||
|
||||
```
|
||||
/degruyter-icons/Simple/{Size}/SVG/{Category}/{Name}-{Size-Code}.svg
|
||||
```
|
||||
|
||||
**Size codes:** `XS`, `SM`, `MD`, `LG`
|
||||
|
||||
### Usage as `<img>` (recommended for static icons)
|
||||
|
||||
SVG fills are hardcoded to `#000000`. Use CSS to tint/size them:
|
||||
|
||||
```svelte
|
||||
<!-- Standard icon -->
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt="" aria-hidden="true" class="w-6 h-6" />
|
||||
|
||||
<!-- Muted/secondary icon -->
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt="" aria-hidden="true" class="w-6 h-6 opacity-40" />
|
||||
|
||||
<!-- Colored via CSS filter (navy tint) -->
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt="" aria-hidden="true"
|
||||
class="w-6 h-6"
|
||||
style="filter: invert(11%) sepia(58%) saturate(1200%) hue-rotate(192deg) brightness(95%) contrast(101%)" />
|
||||
```
|
||||
|
||||
> **Note:** Always include `alt=""` and `aria-hidden="true"` for decorative icons. For meaningful icons (no visible label next to them), use a descriptive `alt` text instead.
|
||||
|
||||
### Key icons for this app
|
||||
|
||||
| Use case | Icon path |
|
||||
|---|---|
|
||||
| Edit / Bearbeiten | `Action/Edit-Content-MD.svg` |
|
||||
| Search / Suche | `Action/Mag-Glass-MD.svg` |
|
||||
| New document | `Action/Add/Add-General-MD.svg` |
|
||||
| Download | `Action/Download-MD.svg` |
|
||||
| Upload | `Action/Upload-MD.svg` |
|
||||
| Filter | `Action/Filter/Filter-Outline-MD.svg` |
|
||||
| Calendar / date | `Action/Calendar/Calendar-Add-MD.svg` |
|
||||
| Location | `Action/Location-MD.svg` |
|
||||
| Person / account | `Action/Account-MD.svg` |
|
||||
| Chat / conversation | `Action/Chat-MD.svg` |
|
||||
| Tag / bookmark | `Action/Bookmark/Bookmark-Outline-MD.svg` |
|
||||
| Close / dismiss | `Action/Close-MD.svg` |
|
||||
| Back / left arrow | `Action/Arrow/Arrow-Left-MD.svg` |
|
||||
| Settings / admin | `Action/Settings-MD.svg` |
|
||||
| Document / PDF | `Action/PDF-Document-MD.svg` |
|
||||
| Mail | `Action/Mail-MD.svg` |
|
||||
| Delete | `Action/Remove/Remove-General-MD.svg` |
|
||||
| Info | `Action/Info/Block/Info-Block-Border-MD.svg` |
|
||||
|
||||
---
|
||||
|
||||
## Spacing
|
||||
|
||||
Based on Tailwind's 4pt grid. Prefer multiples of 4 for all spacing.
|
||||
|
||||
| Scale | Value | Use |
|
||||
|---|---|---|
|
||||
| `p-1` / `gap-1` | 4px | Tight inline spacing |
|
||||
| `p-2` | 8px | Small padding (badges, chips) |
|
||||
| `p-3` | 12px | Compact buttons |
|
||||
| `p-4` | 16px | Default section padding |
|
||||
| `p-6` | 24px | Card inner padding (default) |
|
||||
| `p-8` | 32px | Large card padding |
|
||||
| `p-10` | 40px | Page vertical padding |
|
||||
| `gap-6` | 24px | Grid/list gaps |
|
||||
| `mb-6` | 24px | Standard spacing between sections |
|
||||
| `mb-10` | 40px | Large spacing between card sections |
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
### Page wrapper
|
||||
|
||||
All content pages use:
|
||||
```svelte
|
||||
<div class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
||||
```
|
||||
|
||||
Narrower pages (forms, detail views):
|
||||
```svelte
|
||||
<div class="max-w-4xl mx-auto py-10 px-4">
|
||||
```
|
||||
|
||||
### Header
|
||||
|
||||
The global sticky header in `+layout.svelte`:
|
||||
- Height: **68px** (4px purple accent strip + 64px nav bar)
|
||||
- Background: `bg-white`
|
||||
- Bottom border: `border-b border-gray-100`
|
||||
- Z-index: `z-50`
|
||||
|
||||
### Full-screen views
|
||||
|
||||
Document detail (`/documents/[id]`) uses a full-viewport split layout:
|
||||
```svelte
|
||||
<div class="h-screen flex flex-col bg-white">
|
||||
<!-- top bar -->
|
||||
<!-- content: sidebar + preview -->
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Card
|
||||
|
||||
Standard content card:
|
||||
```svelte
|
||||
<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">
|
||||
Section Title
|
||||
</h2>
|
||||
<!-- content -->
|
||||
</div>
|
||||
```
|
||||
|
||||
Card with colored accent bar (person/document detail):
|
||||
```svelte
|
||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm overflow-hidden">
|
||||
<div class="h-2 bg-brand-navy w-full"></div>
|
||||
<div class="p-8 md:p-10">
|
||||
<!-- content -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Buttons
|
||||
|
||||
Primary button:
|
||||
```svelte
|
||||
<button class="bg-brand-navy text-white px-5 py-2 text-xs font-bold uppercase tracking-widest font-sans hover:bg-brand-navy/90 transition-colors">
|
||||
Speichern
|
||||
</button>
|
||||
```
|
||||
|
||||
Secondary / outline button:
|
||||
```svelte
|
||||
<button class="border border-gray-300 text-gray-600 px-5 py-2 text-xs font-bold uppercase tracking-widest font-sans hover:bg-gray-50 transition-colors rounded-sm">
|
||||
Abbrechen
|
||||
</button>
|
||||
```
|
||||
|
||||
Ghost / text button (inline actions):
|
||||
```svelte
|
||||
<button class="text-brand-navy/60 hover:text-brand-navy text-sm font-medium font-sans transition-colors">
|
||||
Aktion
|
||||
</button>
|
||||
```
|
||||
|
||||
Destructive button:
|
||||
```svelte
|
||||
<button class="border border-red-300 text-red-600 px-4 py-2 text-xs font-bold uppercase tracking-widest font-sans hover:bg-red-50 transition-colors rounded-sm">
|
||||
Löschen
|
||||
</button>
|
||||
```
|
||||
|
||||
Button with DGB icon:
|
||||
```svelte
|
||||
<button class="inline-flex items-center gap-2 bg-brand-navy text-white px-4 py-2 text-xs font-bold uppercase tracking-widest font-sans hover:bg-brand-navy/90 transition-colors">
|
||||
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg"
|
||||
alt="" aria-hidden="true" class="w-4 h-4 invert" />
|
||||
Bearbeiten
|
||||
</button>
|
||||
```
|
||||
|
||||
> Use `class="invert"` on icons inside dark (navy) buttons to make the black SVG white.
|
||||
|
||||
### Form inputs
|
||||
|
||||
Label + input pair:
|
||||
```svelte
|
||||
<div>
|
||||
<label for="field" class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-1.5 font-sans">
|
||||
Feldname *
|
||||
</label>
|
||||
<input
|
||||
id="field" name="field" type="text"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
Search input:
|
||||
```svelte
|
||||
<div class="relative">
|
||||
<input type="text" placeholder="Suchen..."
|
||||
class="block w-full border border-gray-300 py-2.5 pr-10 pl-3 font-sans text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none" />
|
||||
<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="w-4 h-4 opacity-40" />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Status badges
|
||||
|
||||
```svelte
|
||||
<!-- Mint (uploaded/active) -->
|
||||
<span class="inline-flex items-center rounded-full border border-brand-mint/50 bg-brand-mint/20 text-brand-navy px-2.5 py-0.5 text-[10px] font-bold tracking-wide uppercase font-sans">
|
||||
UPLOADED
|
||||
</span>
|
||||
|
||||
<!-- Yellow (placeholder/pending) -->
|
||||
<span class="inline-flex items-center rounded-full border border-yellow-200 bg-yellow-50 text-yellow-700 px-2.5 py-0.5 text-[10px] font-bold tracking-wide uppercase font-sans">
|
||||
PLACEHOLDER
|
||||
</span>
|
||||
```
|
||||
|
||||
### Tag chips
|
||||
|
||||
```svelte
|
||||
<button class="inline-flex items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase font-sans transition-colors hover:bg-brand-navy hover:text-white">
|
||||
Schlagwort
|
||||
</button>
|
||||
```
|
||||
|
||||
### Back link
|
||||
|
||||
```svelte
|
||||
<a href="/persons" class="inline-flex items-center text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy transition-colors group mb-4 font-sans">
|
||||
<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 opacity-40 group-hover:opacity-100 transition-opacity" />
|
||||
Zurück zur Übersicht
|
||||
</a>
|
||||
```
|
||||
|
||||
### Subtle "new item" link
|
||||
|
||||
```svelte
|
||||
<a href="/documents/new" class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 hover:text-brand-navy transition-colors font-sans">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||
alt="" aria-hidden="true" class="w-4 h-4 opacity-60" />
|
||||
Neues Dokument
|
||||
</a>
|
||||
```
|
||||
|
||||
### Empty state
|
||||
|
||||
```svelte
|
||||
<div class="p-16 text-center">
|
||||
<div class="mx-auto mb-4 w-16 h-16 flex items-center justify-center">
|
||||
<img src="/degruyter-icons/Simple/Large-32px/SVG/Action/Mag-Glass-LG.svg"
|
||||
alt="" aria-hidden="true" class="w-10 h-10 opacity-20" />
|
||||
</div>
|
||||
<h3 class="font-serif text-lg font-medium text-brand-navy">Keine Dokumente gefunden</h3>
|
||||
<p class="mt-1 font-sans text-sm text-gray-500">Versuchen Sie, die Filter anzupassen.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Nav active state
|
||||
|
||||
Current page nav link:
|
||||
```svelte
|
||||
class="text-brand-navy bg-brand-purple/15 rounded"
|
||||
```
|
||||
|
||||
Inactive nav link:
|
||||
```svelte
|
||||
class="text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded"
|
||||
```
|
||||
|
||||
Both share the base: `inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors`
|
||||
|
||||
### Save bar (long forms)
|
||||
|
||||
Sticky full-bleed (document edit):
|
||||
```svelte
|
||||
<div class="sticky bottom-0 z-10 -mx-4 px-6 py-4 bg-white border-t border-brand-sand shadow-[0_-2px_8px_rgba(0,0,0,0.06)] flex items-center justify-between">
|
||||
<a href="..." class="text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy font-sans transition-colors">
|
||||
Abbrechen
|
||||
</a>
|
||||
<button type="submit" class="bg-brand-navy text-white px-6 py-2.5 text-xs font-bold uppercase tracking-widest font-sans hover:bg-brand-navy/90 transition-colors">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
Card-style (short forms):
|
||||
```svelte
|
||||
<div class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Do / Don't
|
||||
|
||||
| Do | Don't |
|
||||
|---|---|
|
||||
| Use `font-sans` for all UI labels, buttons, nav | Use `font-serif` for buttons or labels |
|
||||
| Use `uppercase tracking-widest` for labels | Use sentence case for field labels |
|
||||
| Use `brand-navy` for primary actions | Use `brand-mint` as primary action color |
|
||||
| Use `opacity-*` to create icon tints | Change icon fill color inline |
|
||||
| Use `invert` class on icons inside `bg-brand-navy` buttons | Use colored icon files for button icons |
|
||||
| Use `rounded-sm` for cards and buttons (subtle) | Use `rounded-full` for non-pill elements |
|
||||
| Use `shadow-sm` for card elevation | Use large shadows |
|
||||
| Keep borders `border-gray-100` or `border-brand-sand` | Use dark borders |
|
||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -28,3 +28,4 @@ src/lib/paraglide
|
||||
# Generated OpenAPI types — regenerate with: npm run generate:api
|
||||
# (committed as a stub; overwritten by the real spec after generation)
|
||||
# src/lib/generated/api.ts
|
||||
src/lib/paraglide_bak*
|
||||
|
||||
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
|
||||
/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,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": [
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
|
||||
15
frontend/Dockerfile
Normal file
15
frontend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies as a separate layer so they are cached when only source changes
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Source is mounted at runtime via docker-compose volume
|
||||
# This COPY is only used when building without a volume (e.g. production image)
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
0
frontend/e2e/.auth/.gitkeep
Normal file
0
frontend/e2e/.auth/.gitkeep
Normal file
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": []
|
||||
}
|
||||
25
frontend/e2e/auth.setup.ts
Normal file
25
frontend/e2e/auth.setup.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { test as setup } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const authFile = path.join(__dirname, '.auth/user.json');
|
||||
|
||||
/**
|
||||
* Logs in once and saves the session cookie so all E2E tests can reuse it.
|
||||
* Configure credentials via environment variables:
|
||||
* E2E_USERNAME (default: admin)
|
||||
* E2E_PASSWORD (default: admin123)
|
||||
*/
|
||||
setup('authenticate', async ({ page }) => {
|
||||
const username = process.env.E2E_USERNAME ?? 'admin';
|
||||
const password = process.env.E2E_PASSWORD ?? 'admin123';
|
||||
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Benutzername').fill(username);
|
||||
await page.getByLabel('Passwort').fill(password);
|
||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||
await page.waitForURL('/');
|
||||
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
60
frontend/e2e/auth.spec.ts
Normal file
60
frontend/e2e/auth.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
/**
|
||||
* These tests run WITHOUT the stored session so they can test the login flow itself.
|
||||
* Playwright's storageState is only applied for the 'chromium' project, which depends
|
||||
* on the 'setup' project. These tests use a fresh context via test.use({ storageState: undefined }).
|
||||
*/
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
test('login page renders correctly', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await expect(page.getByLabel('Benutzername')).toBeVisible();
|
||||
await expect(page.getByLabel('Passwort')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Anmelden' })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/login-page.png' });
|
||||
});
|
||||
|
||||
test('redirects unauthenticated users to /login', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await page.screenshot({ path: 'test-results/e2e/auth-redirect.png' });
|
||||
});
|
||||
|
||||
test('protected routes redirect to /login without session', async ({ page }) => {
|
||||
for (const url of ['/documents/new', '/persons', '/conversations']) {
|
||||
await page.goto(url);
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
}
|
||||
});
|
||||
|
||||
test('shows an error for wrong credentials', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Benutzername').fill('nichtexistent');
|
||||
await page.getByLabel('Passwort').fill('falschespasswort');
|
||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||
// Stays on login, shows error
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.locator('.text-red-600')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/login-error.png' });
|
||||
});
|
||||
|
||||
test('login with valid credentials redirects to home', async ({ page }) => {
|
||||
await login(page);
|
||||
await expect(page).toHaveURL('/');
|
||||
await expect(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/login-success.png' });
|
||||
});
|
||||
|
||||
test('logout clears the session and redirects to /login', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.getByRole('button', { name: 'Abmelden' }).click();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
// Confirm session is gone: navigating to / redirects back
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await page.screenshot({ path: 'test-results/e2e/logout.png' });
|
||||
});
|
||||
});
|
||||
109
frontend/e2e/documents.spec.ts
Normal file
109
frontend/e2e/documents.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Document management E2E tests.
|
||||
* Assumes auth setup has run (storageState is applied by playwright.config.ts).
|
||||
* Assumes the backend has at least one document in the database.
|
||||
*/
|
||||
test.describe('Document list', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Wait for SvelteKit hydration to complete so onclick/oninput handlers are active.
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
});
|
||||
|
||||
test('renders the search bar and document list', async ({ page }) => {
|
||||
await expect(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Neues Dokument/i })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/documents-home.png' });
|
||||
});
|
||||
|
||||
test('navigation bar shows active state for Dokumente', async ({ page }) => {
|
||||
const navLink = page.getByRole('navigation').getByRole('link', { name: 'Dokumente' });
|
||||
await expect(navLink).toHaveClass(/text-brand-navy/);
|
||||
});
|
||||
|
||||
test('text search filters the document list', async ({ page }) => {
|
||||
// Navigate directly with the query param — tests that search results are filtered
|
||||
// correctly without depending on the debounced oninput → goto chain in CI.
|
||||
await page.goto('/?q=zzz_unlikely_to_match_anything');
|
||||
await expect(page.getByText('Keine Dokumente gefunden')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/documents-search-no-results.png' });
|
||||
});
|
||||
|
||||
test('clearing the search returns all documents', async ({ page }) => {
|
||||
// Navigate with an active query first, then click the reset link.
|
||||
await page.goto('/?q=xyz_unlikely');
|
||||
await page.getByTitle('Filter zurücksetzen').click();
|
||||
await page.waitForURL('/');
|
||||
await expect(page).toHaveURL('/');
|
||||
await page.screenshot({ path: 'test-results/e2e/documents-reset-search.png' });
|
||||
});
|
||||
|
||||
test('advanced filters panel opens and closes', async ({ page }) => {
|
||||
const btn = page.getByRole('button', { name: 'Filter', exact: true });
|
||||
await btn.click();
|
||||
await expect(page.getByLabel('Von')).toBeVisible();
|
||||
await expect(page.getByLabel('Bis')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/documents-filters-open.png' });
|
||||
await btn.click();
|
||||
await expect(page.getByLabel('Von')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('date range filter triggers a new search', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Filter', exact: true }).click();
|
||||
await page.getByLabel('Von').fill('2000-01-01');
|
||||
await page.waitForURL(/from=2000-01-01/);
|
||||
await expect(page).toHaveURL(/from=2000-01-01/);
|
||||
await page.screenshot({ path: 'test-results/e2e/documents-date-filter.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Document detail', () => {
|
||||
test('clicking a document opens the detail page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Click the first document link in the list
|
||||
const firstDoc = page.locator('ul li a').first();
|
||||
const href = await firstDoc.getAttribute('href');
|
||||
await firstDoc.click();
|
||||
await expect(page).toHaveURL(href!);
|
||||
await page.screenshot({ path: 'test-results/e2e/document-detail.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('New document', () => {
|
||||
test('renders the upload form', async ({ page }) => {
|
||||
await page.goto('/documents/new');
|
||||
await expect(page.getByRole('heading', { name: /Neues Dokument/i })).toBeVisible();
|
||||
await expect(page.getByLabel('Titel')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/document-new.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Document edit', () => {
|
||||
test('renders the edit form with pre-filled data', async ({ page }) => {
|
||||
// Navigate to home, find first document, go to its edit page
|
||||
await page.goto('/');
|
||||
const firstDocLink = page.locator('ul li a').first();
|
||||
const href = await firstDocLink.getAttribute('href');
|
||||
await page.goto(`${href}/edit`);
|
||||
await expect(page.getByRole('heading', { name: /Bearbeiten/i })).toBeVisible();
|
||||
await expect(page.getByLabel('Titel')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/document-edit.png' });
|
||||
});
|
||||
|
||||
test('shows a validation error for an invalid date format', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const firstDocLink = page.locator('ul li a').first();
|
||||
const href = await firstDocLink.getAttribute('href');
|
||||
await page.goto(`${href}/edit`);
|
||||
// Wait for hydration so oninput={handleDateInput} is registered.
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
const dateInput = page.getByLabel('Datum');
|
||||
// Type partial digits: '99' → dateDisplay='99', dateIso='' → dateInvalid=true
|
||||
await dateInput.fill('');
|
||||
await dateInput.pressSequentially('99');
|
||||
await expect(page.getByText(/TT\.MM\.JJJJ/i)).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
|
||||
});
|
||||
});
|
||||
13
frontend/e2e/helpers/auth.ts
Normal file
13
frontend/e2e/helpers/auth.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export async function login(
|
||||
page: Page,
|
||||
username = process.env.E2E_USERNAME ?? 'admin',
|
||||
password = process.env.E2E_PASSWORD ?? 'admin123'
|
||||
) {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Benutzername').fill(username);
|
||||
await page.getByLabel('Passwort').fill(password);
|
||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||
await page.waitForURL('/');
|
||||
}
|
||||
60
frontend/e2e/lang.spec.ts
Normal file
60
frontend/e2e/lang.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Language selector', () => {
|
||||
test('shows DE, EN, ES buttons in the header', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(
|
||||
page.getByRole('banner').getByRole('button', { name: 'DE', exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('banner').getByRole('button', { name: 'EN', exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('banner').getByRole('button', { name: 'ES', exact: true })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('switching to EN translates the navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('navigation').getByRole('link', { name: 'Documents' })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('navigation').getByRole('link', { name: 'Persons' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('language choice persists after navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
|
||||
await page.goto('/persons');
|
||||
await expect(
|
||||
page.getByRole('navigation').getByRole('link', { name: 'Documents' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('switching back to DE restores German', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('navigation').getByRole('link', { name: 'Documents' })
|
||||
).toBeVisible();
|
||||
await page.getByRole('banner').getByRole('button', { name: 'DE', exact: true }).click();
|
||||
// In headless Chromium, cookie deletion via document.cookie can be unreliable.
|
||||
// Delete the PARAGLIDE_LOCALE cookie directly so the next navigation defaults to DE.
|
||||
await page.context().clearCookies({ name: 'PARAGLIDE_LOCALE' });
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await expect(
|
||||
page.getByRole('navigation').getByRole('link', { name: 'Dokumente' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('active language button is visually highlighted', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const deBtn = page.getByRole('banner').getByRole('button', { name: 'DE', exact: true });
|
||||
await expect(deBtn).toHaveClass(/font-bold/);
|
||||
});
|
||||
});
|
||||
31
frontend/e2e/permissions.spec.ts
Normal file
31
frontend/e2e/permissions.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Write permissions — admin user', () => {
|
||||
test('admin user sees Neues Dokument link on home page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('link', { name: /Neues Dokument/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin user sees Neue Person link on persons page', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
await expect(page.getByRole('link', { name: /Neue Person/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin user can navigate to /persons/new', async ({ page }) => {
|
||||
await page.goto('/persons/new');
|
||||
await expect(page).toHaveURL('/persons/new');
|
||||
await expect(page.getByLabel('Vorname')).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin user can navigate to /documents/new', async ({ page }) => {
|
||||
await page.goto('/documents/new');
|
||||
await expect(page).toHaveURL('/documents/new');
|
||||
});
|
||||
|
||||
test('admin user sees edit button on person detail page', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
||||
await firstPerson.click();
|
||||
await expect(page.getByRole('button', { name: /Bearbeiten/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
294
frontend/e2e/persons.spec.ts
Normal file
294
frontend/e2e/persons.spec.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Person list', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
});
|
||||
|
||||
test('renders the persons list page', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: /Personen/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Neue Person/i })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/persons-list.png' });
|
||||
});
|
||||
|
||||
test('search filters the persons list', async ({ page }) => {
|
||||
// Navigate directly with the query param — tests that search results are filtered
|
||||
// correctly without depending on the debounced oninput → goto chain in CI.
|
||||
await page.goto('/persons?q=zzz_unlikely_match');
|
||||
await expect(page.getByText(/Keine Personen gefunden/i)).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/persons-search-empty.png' });
|
||||
});
|
||||
|
||||
test('clicking a person opens the detail page', async ({ page }) => {
|
||||
const firstPerson = page.locator('a[href^="/persons/"]').first();
|
||||
await firstPerson.click();
|
||||
await expect(page).toHaveURL(/\/persons\/.+/);
|
||||
await page.screenshot({ path: 'test-results/e2e/person-detail.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Person detail', () => {
|
||||
test('shows the person name and their documents', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPerson = page.locator('a[href^="/persons/"]').first();
|
||||
await firstPerson.click();
|
||||
// The detail page shows the person's name as the top-level heading
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/person-detail-documents.png' });
|
||||
});
|
||||
|
||||
test('can enter and cancel edit mode', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPerson = page.locator('a[href^="/persons/"]').first();
|
||||
await firstPerson.click();
|
||||
// Click the edit button
|
||||
const editBtn = page.getByRole('button', { name: /Bearbeiten/i });
|
||||
if (await editBtn.isVisible()) {
|
||||
await editBtn.click();
|
||||
await expect(page.getByLabel('Vorname')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/person-edit-form.png' });
|
||||
// Cancel
|
||||
await page.getByRole('button', { name: /Abbrechen/i }).click();
|
||||
await expect(page.getByLabel('Vorname')).not.toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('birth and death year fields appear in edit mode and save correctly', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
||||
await firstPerson.click();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const editBtn = page.getByRole('button', { name: /Bearbeiten/i });
|
||||
await editBtn.click();
|
||||
|
||||
await expect(page.getByLabel(/Geburtsjahr/i)).toBeVisible();
|
||||
await expect(page.getByLabel(/Todesjahr/i)).toBeVisible();
|
||||
|
||||
await page.getByLabel(/Geburtsjahr/i).fill('1890');
|
||||
await page.getByLabel(/Todesjahr/i).fill('1965');
|
||||
|
||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||
|
||||
// After saving, the years should be shown in view mode
|
||||
await expect(page.getByText('* 1890')).toBeVisible();
|
||||
await expect(page.getByText('† 1965')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/person-birth-death-years.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('New person', () => {
|
||||
test('renders the new person form', async ({ page }) => {
|
||||
await page.goto('/persons/new');
|
||||
await expect(page.getByLabel('Vorname')).toBeVisible();
|
||||
await expect(page.getByLabel('Nachname')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Erstellen/i })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/person-new.png' });
|
||||
});
|
||||
|
||||
test('shows a validation error when submitting with empty fields', async ({ page }) => {
|
||||
await page.goto('/persons/new');
|
||||
// HTML required attribute prevents submission without filling required fields
|
||||
await page.getByRole('button', { name: /Erstellen/i }).click();
|
||||
// The form should not have navigated away
|
||||
await expect(page).toHaveURL('/persons/new');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Person detail — sort toggle', () => {
|
||||
test('each section has its own sort toggle that works independently', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPerson = page.locator('a[href^="/persons/"]').first();
|
||||
await firstPerson.click();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Find sort buttons — there may be 0, 1 or 2 depending on whether sections have >1 doc
|
||||
const sortBtns = page.getByRole('button', { name: /Neueste zuerst|Älteste zuerst/i });
|
||||
const btnCount = await sortBtns.count();
|
||||
|
||||
if (btnCount >= 1) {
|
||||
const firstBtn = sortBtns.first();
|
||||
await expect(firstBtn).toContainText('Neueste zuerst');
|
||||
await firstBtn.click();
|
||||
await expect(firstBtn).toContainText('Älteste zuerst');
|
||||
await firstBtn.click();
|
||||
await expect(firstBtn).toContainText('Neueste zuerst');
|
||||
}
|
||||
|
||||
if (btnCount >= 2) {
|
||||
// Second sort button toggles independently
|
||||
const secondBtn = sortBtns.nth(1);
|
||||
await expect(secondBtn).toContainText('Neueste zuerst');
|
||||
await secondBtn.click();
|
||||
await expect(secondBtn).toContainText('Älteste zuerst');
|
||||
// First button should be unaffected
|
||||
await expect(sortBtns.first()).toContainText('Neueste zuerst');
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/person-sort-toggle.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Person detail — sent and received documents', () => {
|
||||
test('shows both sent and received document sections', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
||||
await firstPerson.click();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Gesendete Dokumente/i })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /Empfangene Dokumente/i })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/person-sent-received.png' });
|
||||
});
|
||||
|
||||
test('shows year range next to document count when documents have dates', async ({ page }) => {
|
||||
// Navigate to the first person who has documents with dates
|
||||
await page.goto('/persons');
|
||||
const personLinks = page.locator('a[href^="/persons/"]:not([href="/persons/new"])');
|
||||
const count = await personLinks.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
await page.goto('/persons');
|
||||
await personLinks.nth(i).click();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Check if either section heading has a year range (4 digits)
|
||||
const sentHeading = page.getByRole('heading', { name: /Gesendete Dokumente/i }).locator('..');
|
||||
const hasYearRange = await sentHeading.locator('span').filter({ hasText: /\d{4}/ }).count();
|
||||
if (hasYearRange > 0) {
|
||||
await expect(
|
||||
sentHeading.locator('span').filter({ hasText: /\d{4}/ }).first()
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/person-year-range.png' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If no person has dated documents, the test is a no-op (year range is optional)
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Person detail — conversations link', () => {
|
||||
test('co-correspondent chips link to conversations pre-filled with both persons', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/persons');
|
||||
const firstLink = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
||||
const href = await firstLink.getAttribute('href');
|
||||
const personId = href!.split('/persons/')[1];
|
||||
await firstLink.click();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Co-correspondent chips link to /conversations?senderId=X&receiverId=Y
|
||||
const chip = page.locator(`a[href^="/conversations?senderId=${personId}&receiverId="]`).first();
|
||||
if ((await chip.count()) > 0) {
|
||||
const chipHref = await chip.getAttribute('href');
|
||||
expect(chipHref).toMatch(/\/conversations\?senderId=.+&receiverId=.+/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Conversations', () => {
|
||||
test('shows the empty state when no persons are selected', async ({ page }) => {
|
||||
await page.goto('/conversations');
|
||||
await expect(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/conversations-empty.png' });
|
||||
});
|
||||
|
||||
test('nav link is active on the conversations page', async ({ page }) => {
|
||||
await page.goto('/conversations');
|
||||
const navLink = page.getByRole('link', { name: 'Konversationen' });
|
||||
await expect(navLink).toHaveClass(/text-brand-navy/);
|
||||
});
|
||||
|
||||
test('sort toggle changes the button label', async ({ page }) => {
|
||||
await page.goto('/conversations');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
const btn = page.getByRole('button', { name: /Sortierung/i });
|
||||
await expect(btn).toContainText('Neueste zuerst');
|
||||
await btn.click();
|
||||
await expect(page).toHaveURL(/dir=ASC/);
|
||||
await expect(btn).toContainText('Älteste zuerst');
|
||||
await page.screenshot({ path: 'test-results/e2e/conversations-sort.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Conversations — enhancements', () => {
|
||||
// Hans→Anna (1923) and Anna→Hans (1965) are seeded in DataInitializer
|
||||
// Navigate directly by URL so the test doesn't rely on typeahead interaction
|
||||
async function loadHansAnnaConversation(page: import('@playwright/test').Page) {
|
||||
// Resolve person IDs from the persons list
|
||||
await page.goto('/persons');
|
||||
const hansLink = page.getByRole('link', { name: /Hans Müller/ }).first();
|
||||
const hansHref = await hansLink.getAttribute('href');
|
||||
const hansId = hansHref!.split('/').pop()!;
|
||||
|
||||
const annaLink = page.getByRole('link', { name: /Anna Schmidt/ }).first();
|
||||
const annaHref = await annaLink.getAttribute('href');
|
||||
const annaId = annaHref!.split('/').pop()!;
|
||||
|
||||
await page.goto(`/conversations?senderId=${hansId}&receiverId=${annaId}`);
|
||||
await page.waitForURL(/senderId=/);
|
||||
}
|
||||
|
||||
test('shows document count and year range summary when both persons are selected', async ({
|
||||
page
|
||||
}) => {
|
||||
await loadHansAnnaConversation(page);
|
||||
// Hans→Anna (1923) + Anna→Hans (1965) = 2 documents, range 1923–1965
|
||||
await expect(page.getByTestId('conv-summary')).toContainText('2');
|
||||
await expect(page.getByTestId('conv-summary')).toContainText('1923');
|
||||
await expect(page.getByTestId('conv-summary')).toContainText('1965');
|
||||
await page.screenshot({ path: 'test-results/e2e/conversations-summary.png' });
|
||||
});
|
||||
|
||||
test('shows year dividers between documents from different years', async ({ page }) => {
|
||||
await loadHansAnnaConversation(page);
|
||||
// Expect at least two year dividers (1923 and 1965)
|
||||
await expect(page.getByTestId('year-divider').first()).toBeVisible();
|
||||
const dividers = page.getByTestId('year-divider');
|
||||
const texts = await dividers.allTextContents();
|
||||
expect(texts.some((t) => t.includes('1923'))).toBe(true);
|
||||
expect(texts.some((t) => t.includes('1965'))).toBe(true);
|
||||
await page.screenshot({ path: 'test-results/e2e/conversations-year-dividers.png' });
|
||||
});
|
||||
|
||||
test('swap button switches sender and receiver and reloads', async ({ page }) => {
|
||||
await loadHansAnnaConversation(page);
|
||||
const url = new URL(page.url());
|
||||
const originalSenderId = url.searchParams.get('senderId')!;
|
||||
const originalReceiverId = url.searchParams.get('receiverId')!;
|
||||
|
||||
await page.getByTestId('conv-swap-btn').click();
|
||||
await page.waitForURL(/senderId=/);
|
||||
|
||||
const swappedUrl = new URL(page.url());
|
||||
expect(swappedUrl.searchParams.get('senderId')).toBe(originalReceiverId);
|
||||
expect(swappedUrl.searchParams.get('receiverId')).toBe(originalSenderId);
|
||||
await page.screenshot({ path: 'test-results/e2e/conversations-swap.png' });
|
||||
});
|
||||
|
||||
test('shows "new document" link pre-filled with both persons when conversation is loaded', async ({
|
||||
page
|
||||
}) => {
|
||||
await loadHansAnnaConversation(page);
|
||||
const url = new URL(page.url());
|
||||
const senderId = url.searchParams.get('senderId')!;
|
||||
const receiverId = url.searchParams.get('receiverId')!;
|
||||
|
||||
const link = page.getByTestId('conv-new-doc-link');
|
||||
await expect(link).toBeVisible();
|
||||
const href = await link.getAttribute('href');
|
||||
expect(href).toContain(`senderId=${senderId}`);
|
||||
expect(href).toContain(`receiverId=${receiverId}`);
|
||||
await page.screenshot({ path: 'test-results/e2e/conversations-new-doc-link.png' });
|
||||
});
|
||||
|
||||
test('does not show swap button or new document link when only one person is selected', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/conversations');
|
||||
await page.waitForURL('/conversations');
|
||||
await expect(page.getByTestId('conv-swap-btn')).not.toBeVisible();
|
||||
await expect(page.getByTestId('conv-new-doc-link')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -21,16 +21,17 @@ export default defineConfig(
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node }
|
||||
},
|
||||
rules: { // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
"no-undef": 'off' }
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off',
|
||||
// This rule is designed for Svelte 5's own routing system using resolve().
|
||||
// In SvelteKit, <a href> and goto() from $app/navigation are the correct patterns — resolve() is not needed.
|
||||
'svelte/no-navigation-without-resolve': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'**/*.svelte',
|
||||
'**/*.svelte.ts',
|
||||
'**/*.svelte.js'
|
||||
],
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"hello_world": "Hello, {name} from de!",
|
||||
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
|
||||
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
||||
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
||||
@@ -10,5 +9,190 @@
|
||||
"error_unauthorized": "Sie sind nicht angemeldet.",
|
||||
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.",
|
||||
"error_validation_error": "Die Eingabe ist ungültig.",
|
||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten."
|
||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||
"nav_documents": "Dokumente",
|
||||
"nav_persons": "Personen",
|
||||
"nav_conversations": "Konversationen",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Abmelden",
|
||||
"btn_save": "Speichern",
|
||||
"btn_cancel": "Abbrechen",
|
||||
"btn_edit": "Bearbeiten",
|
||||
"btn_create": "Erstellen",
|
||||
"btn_delete": "Löschen",
|
||||
"btn_back_to_overview": "Zurück zur Übersicht",
|
||||
"btn_back": "Zurück",
|
||||
"btn_back_to_document": "Zurück zum Dokument",
|
||||
"form_label_first_name": "Vorname",
|
||||
"form_label_last_name": "Nachname",
|
||||
"form_label_alias": "Rufname / Alias",
|
||||
"form_placeholder_alias": "z.B. Oma Frieda, Onkel Karl…",
|
||||
"form_label_date": "Datum",
|
||||
"form_placeholder_date": "TT.MM.JJJJ",
|
||||
"form_date_error": "Bitte im Format TT.MM.JJJJ eingeben, z.B. 20.12.2026",
|
||||
"form_label_location": "Ort",
|
||||
"form_placeholder_location": "z.B. Berlin, Wien…",
|
||||
"form_label_sender": "Absender",
|
||||
"form_label_receivers": "Empfänger",
|
||||
"form_label_title": "Titel *",
|
||||
"form_label_tags": "Schlagworte",
|
||||
"form_label_content": "Inhalt",
|
||||
"form_placeholder_content": "Kurze Beschreibung des Inhalts…",
|
||||
"form_label_transcription": "Transkription",
|
||||
"form_placeholder_transcription": "Vollständiger Text des Dokuments…",
|
||||
"form_label_archive_location": "Aufbewahrungsort",
|
||||
"form_placeholder_archive_location": "z.B. Schrank 3, Mappe B",
|
||||
"form_helper_archive_location": "Wo befindet sich das Originaldokument?",
|
||||
"login_heading": "Anmelden",
|
||||
"login_label_username": "Benutzername",
|
||||
"login_label_password": "Passwort",
|
||||
"login_btn_submit": "Anmelden",
|
||||
"docs_search_placeholder": "Suche in Titel, Inhalt, Ort...",
|
||||
"docs_btn_filter": "Filter",
|
||||
"docs_btn_reset_title": "Filter zurücksetzen",
|
||||
"docs_filter_label_tags": "Schlagworte",
|
||||
"docs_filter_label_sender": "Absender",
|
||||
"docs_filter_label_receivers": "Empfänger",
|
||||
"docs_filter_label_from": "Von",
|
||||
"docs_filter_label_to": "Bis",
|
||||
"docs_btn_new": "Neues Dokument",
|
||||
"docs_empty_heading": "Keine Dokumente gefunden",
|
||||
"docs_empty_text": "Versuchen Sie, die Filter anzupassen oder den Suchbegriff zu ändern.",
|
||||
"docs_empty_btn_clear": "Alle Filter löschen",
|
||||
"docs_list_from": "Von",
|
||||
"docs_list_to": "An",
|
||||
"docs_list_unknown": "Unbekannt",
|
||||
"doc_section_who_when": "Wer & Wann",
|
||||
"doc_section_description": "Beschreibung",
|
||||
"doc_section_file": "Datei",
|
||||
"doc_file_upload_label": "Datei hochladen",
|
||||
"doc_file_upload_note": "(optional)",
|
||||
"doc_file_replace_label": "Neue Datei hochladen",
|
||||
"doc_file_replace_note": "(ersetzt die aktuelle Datei)",
|
||||
"doc_current_file_label": "Aktuelle Datei:",
|
||||
"doc_new_heading": "Neues Dokument",
|
||||
"doc_edit_heading": "Bearbeiten",
|
||||
"doc_section_details": "Details",
|
||||
"doc_label_document_date": "Dokumentendatum",
|
||||
"doc_label_creation_location": "Erstellungsort",
|
||||
"doc_label_archive_location_original": "Aufbewahrungsort (Original)",
|
||||
"doc_section_persons": "Personen",
|
||||
"doc_sender_not_specified": "Nicht angegeben",
|
||||
"doc_no_receivers": "Keine Empfänger",
|
||||
"doc_section_content": "Inhalt",
|
||||
"doc_label_summary": "Zusammenfassung",
|
||||
"doc_loading": "Lade Dokument...",
|
||||
"doc_download_link": "Direkter Download versuchen",
|
||||
"doc_no_scan": "Kein Scan vorhanden",
|
||||
"persons_heading": "Personenverzeichnis",
|
||||
"persons_subtitle": "Durchsuchen Sie den Index aller erfassten Personen im Familienarchiv.",
|
||||
"persons_btn_new": "Neue Person",
|
||||
"persons_search_placeholder": "Namen suchen...",
|
||||
"persons_empty_heading": "Keine Personen gefunden.",
|
||||
"persons_empty_text": "Versuchen Sie einen anderen Suchbegriff.",
|
||||
"persons_new_heading": "Neue Person",
|
||||
"persons_section_details": "Angaben zur Person",
|
||||
"person_edit_heading": "Person bearbeiten",
|
||||
"person_label_full_name": "Voller Name",
|
||||
"person_merge_heading": "Person zusammenführen",
|
||||
"person_merge_description": "Diese Person wird in die gewählte Zielperson überführt. Alle Dokumente und Verknüpfungen werden übertragen, danach wird diese Person gelöscht.",
|
||||
"person_merge_target_label": "Zusammenführen mit",
|
||||
"person_btn_merge": "Zusammenführen",
|
||||
"person_btn_merge_confirm": "Ja, zusammenführen",
|
||||
"person_merge_warning": "Achtung: Diese Aktion ist nicht rückgängig zu machen.",
|
||||
"person_label_notes": "Notizen",
|
||||
"person_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
|
||||
"person_label_birth_year": "Geburtsjahr",
|
||||
"person_label_death_year": "Todesjahr",
|
||||
"person_placeholder_year": "z.B. 1923",
|
||||
"person_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
|
||||
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
|
||||
"person_docs_heading": "Gesendete Dokumente",
|
||||
"person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.",
|
||||
"person_received_docs_heading": "Empfangene Dokumente",
|
||||
"person_no_received_docs": "Diese Person ist noch nicht als Empfänger verknüpft.",
|
||||
"person_role_sender": "Gesendet",
|
||||
"person_role_receiver": "Empfangen",
|
||||
"person_co_correspondents_heading": "Häufige Korrespondenten",
|
||||
"person_show_more": "+ {count} weitere anzeigen",
|
||||
"conv_heading": "Konversationen",
|
||||
"conv_subtitle": "Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.",
|
||||
"conv_label_person_a": "Person A (Absender)",
|
||||
"conv_label_person_b": "Person B (Empfänger)",
|
||||
"conv_label_from": "Zeitraum von",
|
||||
"conv_label_to": "Zeitraum bis",
|
||||
"conv_sort_label": "Sortierung:",
|
||||
"conv_sort_newest": "Neueste zuerst",
|
||||
"conv_sort_oldest": "Älteste zuerst",
|
||||
"conv_empty_heading": "Wählen Sie zwei Personen aus",
|
||||
"conv_empty_text": "Die Korrespondenz wird hier angezeigt.",
|
||||
"conv_no_results_heading": "Keine Dokumente gefunden.",
|
||||
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
|
||||
"conv_swap_btn": "Personen tauschen",
|
||||
"conv_summary": "{count} Dokumente · {yearFrom}–{yearTo}",
|
||||
"conv_new_doc_link": "Neues Dokument in dieser Korrespondenz",
|
||||
"admin_heading": "Admin Dashboard",
|
||||
"admin_tab_users": "Benutzer",
|
||||
"admin_tab_groups": "Gruppen",
|
||||
"admin_tab_tags": "Schlagworte",
|
||||
"admin_section_users": "Benutzerverwaltung",
|
||||
"admin_col_login": "Login",
|
||||
"admin_col_groups": "Gruppen",
|
||||
"admin_col_password": "Passwort",
|
||||
"admin_multiselect_hint": "Strg+Klick für Auswahl",
|
||||
"admin_password_placeholder": "Neues PW (optional)",
|
||||
"admin_no_groups": "Keine Gruppen",
|
||||
"admin_btn_delete_user_title": "Benutzer löschen",
|
||||
"admin_section_new_user": "Neuen Benutzer anlegen",
|
||||
"admin_multiselect_hint_multi": "Strg+Klick für mehrere",
|
||||
"admin_multiselect_hint_full": "Strg+Klick für Mehrfachauswahl",
|
||||
"admin_section_tags": "Schlagworte",
|
||||
"admin_tags_warning": "Warnung: Umbenennen oder Löschen wirkt sich auf alle verknüpften Dokumente aus.",
|
||||
"admin_btn_edit_tag_label": "Schlagwort bearbeiten",
|
||||
"admin_tag_delete_confirm": "Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.",
|
||||
"admin_btn_delete_tag_label": "Schlagwort löschen",
|
||||
"admin_section_groups": "Gruppenverwaltung",
|
||||
"admin_col_name": "Name",
|
||||
"admin_col_permissions": "Berechtigungen",
|
||||
"admin_col_actions": "Aktionen",
|
||||
"admin_group_delete_confirm": "Gruppe wirklich löschen?",
|
||||
"admin_section_new_group": "Neue Gruppe anlegen",
|
||||
"admin_group_name_placeholder": "Gruppenname (z.B. Editoren)",
|
||||
"admin_user_delete_confirm": "Benutzer {username} wirklich löschen?",
|
||||
"doc_file_error_preview": "Vorschau konnte nicht geladen werden.",
|
||||
"doc_download_title": "Herunterladen",
|
||||
"doc_tag_filter_title": "Nach {name} filtern",
|
||||
"doc_conversation_title": "Konversation anzeigen",
|
||||
"doc_preview_iframe_title": "Dokumentvorschau",
|
||||
"doc_image_alt": "Original-Scan",
|
||||
"doc_no_date": "Kein Datum",
|
||||
"person_merge_will_be_deleted": "wird gelöscht.",
|
||||
"comp_typeahead_placeholder": "Namen tippen...",
|
||||
"comp_typeahead_loading": "Suche...",
|
||||
"comp_multiselect_placeholder": "Namen tippen...",
|
||||
"comp_multiselect_remove": "Entfernen",
|
||||
"comp_multiselect_loading": "Suche...",
|
||||
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
|
||||
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
|
||||
"comp_taginput_remove": "Schlagwort entfernen",
|
||||
"comp_taginput_create_hint": "Enter drücken um Schlagwort zu erstellen.",
|
||||
"error_email_already_in_use": "Diese E-Mail-Adresse wird bereits von einem anderen Konto verwendet.",
|
||||
"error_wrong_current_password": "Das aktuelle Passwort ist falsch.",
|
||||
"nav_profile": "Profil",
|
||||
"profile_heading": "Mein Profil",
|
||||
"profile_section_personal": "Persönliche Daten",
|
||||
"profile_label_first_name": "Vorname",
|
||||
"profile_label_last_name": "Nachname",
|
||||
"profile_label_birth_date": "Geburtsdatum",
|
||||
"profile_label_email": "E-Mail-Adresse",
|
||||
"profile_label_contact": "Kontaktdaten",
|
||||
"profile_contact_placeholder": "Telefon, Adresse oder sonstige Hinweise...",
|
||||
"profile_section_password": "Passwort ändern",
|
||||
"profile_label_current_password": "Aktuelles Passwort",
|
||||
"profile_label_new_password": "Neues Passwort",
|
||||
"profile_label_new_password_confirm": "Neues Passwort (Wiederholung)",
|
||||
"profile_password_mismatch": "Die neuen Passwörter stimmen nicht überein.",
|
||||
"profile_saved": "Gespeichert.",
|
||||
"profile_password_changed": "Passwort erfolgreich geändert.",
|
||||
"user_profile_heading": "Profil von"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"hello_world": "Hello, {name} from en!",
|
||||
"error_document_not_found": "Document not found.",
|
||||
"error_document_no_file": "No file is associated with this document.",
|
||||
"error_file_not_found": "The file could not be found in storage.",
|
||||
@@ -10,5 +9,190 @@
|
||||
"error_unauthorized": "You are not logged in.",
|
||||
"error_forbidden": "You do not have permission for this action.",
|
||||
"error_validation_error": "The input is invalid.",
|
||||
"error_internal_error": "An unexpected error occurred."
|
||||
"error_internal_error": "An unexpected error occurred.",
|
||||
"nav_documents": "Documents",
|
||||
"nav_persons": "Persons",
|
||||
"nav_conversations": "Conversations",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Sign out",
|
||||
"btn_save": "Save",
|
||||
"btn_cancel": "Cancel",
|
||||
"btn_edit": "Edit",
|
||||
"btn_create": "Create",
|
||||
"btn_delete": "Delete",
|
||||
"btn_back_to_overview": "Back to overview",
|
||||
"btn_back": "Back",
|
||||
"btn_back_to_document": "Back to document",
|
||||
"form_label_first_name": "First name",
|
||||
"form_label_last_name": "Last name",
|
||||
"form_label_alias": "Nickname / Alias",
|
||||
"form_placeholder_alias": "e.g. Grandma Frieda, Uncle Karl…",
|
||||
"form_label_date": "Date",
|
||||
"form_placeholder_date": "DD.MM.YYYY",
|
||||
"form_date_error": "Please enter in DD.MM.YYYY format, e.g. 20.12.2026",
|
||||
"form_label_location": "Location",
|
||||
"form_placeholder_location": "e.g. Berlin, Vienna…",
|
||||
"form_label_sender": "Sender",
|
||||
"form_label_receivers": "Recipients",
|
||||
"form_label_title": "Title *",
|
||||
"form_label_tags": "Tags",
|
||||
"form_label_content": "Content",
|
||||
"form_placeholder_content": "Brief description of the content…",
|
||||
"form_label_transcription": "Transcription",
|
||||
"form_placeholder_transcription": "Full text of the document…",
|
||||
"form_label_archive_location": "Storage location",
|
||||
"form_placeholder_archive_location": "e.g. Cabinet 3, Folder B",
|
||||
"form_helper_archive_location": "Where is the original document stored?",
|
||||
"login_heading": "Sign in",
|
||||
"login_label_username": "Username",
|
||||
"login_label_password": "Password",
|
||||
"login_btn_submit": "Sign in",
|
||||
"docs_search_placeholder": "Search in title, content, location...",
|
||||
"docs_btn_filter": "Filter",
|
||||
"docs_btn_reset_title": "Reset filter",
|
||||
"docs_filter_label_tags": "Tags",
|
||||
"docs_filter_label_sender": "Sender",
|
||||
"docs_filter_label_receivers": "Recipients",
|
||||
"docs_filter_label_from": "From",
|
||||
"docs_filter_label_to": "To",
|
||||
"docs_btn_new": "New document",
|
||||
"docs_empty_heading": "No documents found",
|
||||
"docs_empty_text": "Try adjusting the filters or changing the search term.",
|
||||
"docs_empty_btn_clear": "Clear all filters",
|
||||
"docs_list_from": "From",
|
||||
"docs_list_to": "To",
|
||||
"docs_list_unknown": "Unknown",
|
||||
"doc_section_who_when": "Who & When",
|
||||
"doc_section_description": "Description",
|
||||
"doc_section_file": "File",
|
||||
"doc_file_upload_label": "Upload file",
|
||||
"doc_file_upload_note": "(optional)",
|
||||
"doc_file_replace_label": "Upload new file",
|
||||
"doc_file_replace_note": "(replaces the current file)",
|
||||
"doc_current_file_label": "Current file:",
|
||||
"doc_new_heading": "New document",
|
||||
"doc_edit_heading": "Edit",
|
||||
"doc_section_details": "Details",
|
||||
"doc_label_document_date": "Document date",
|
||||
"doc_label_creation_location": "Place of creation",
|
||||
"doc_label_archive_location_original": "Storage location (original)",
|
||||
"doc_section_persons": "Persons",
|
||||
"doc_sender_not_specified": "Not specified",
|
||||
"doc_no_receivers": "No recipients",
|
||||
"doc_section_content": "Content",
|
||||
"doc_label_summary": "Summary",
|
||||
"doc_loading": "Loading document...",
|
||||
"doc_download_link": "Try direct download",
|
||||
"doc_no_scan": "No scan available",
|
||||
"persons_heading": "Person directory",
|
||||
"persons_subtitle": "Browse the index of all recorded persons in the family archive.",
|
||||
"persons_btn_new": "New person",
|
||||
"persons_search_placeholder": "Search names...",
|
||||
"persons_empty_heading": "No persons found.",
|
||||
"persons_empty_text": "Try a different search term.",
|
||||
"persons_new_heading": "New person",
|
||||
"persons_section_details": "Person details",
|
||||
"person_edit_heading": "Edit person",
|
||||
"person_label_full_name": "Full name",
|
||||
"person_merge_heading": "Merge person",
|
||||
"person_merge_description": "This person will be merged into the selected target person. All documents and links will be transferred, then this person will be deleted.",
|
||||
"person_merge_target_label": "Merge with",
|
||||
"person_btn_merge": "Merge",
|
||||
"person_btn_merge_confirm": "Yes, merge",
|
||||
"person_merge_warning": "Warning: This action cannot be undone.",
|
||||
"person_label_notes": "Notes",
|
||||
"person_placeholder_notes": "Biographical notes, remarks…",
|
||||
"person_label_birth_year": "Birth year",
|
||||
"person_label_death_year": "Death year",
|
||||
"person_placeholder_year": "e.g. 1923",
|
||||
"person_year_error": "Please enter a four-digit year",
|
||||
"person_years_error_order": "Birth year must be before death year",
|
||||
"person_docs_heading": "Sent documents",
|
||||
"person_no_docs": "This person has not yet been linked as a sender.",
|
||||
"person_received_docs_heading": "Received documents",
|
||||
"person_no_received_docs": "This person has not yet been linked as a receiver.",
|
||||
"person_role_sender": "Sent",
|
||||
"person_role_receiver": "Received",
|
||||
"person_co_correspondents_heading": "Frequent correspondents",
|
||||
"person_show_more": "+ {count} more",
|
||||
"conv_heading": "Conversations",
|
||||
"conv_subtitle": "Follow the correspondence between two persons chronologically.",
|
||||
"conv_label_person_a": "Person A (Sender)",
|
||||
"conv_label_person_b": "Person B (Recipient)",
|
||||
"conv_label_from": "Period from",
|
||||
"conv_label_to": "Period to",
|
||||
"conv_sort_label": "Sort:",
|
||||
"conv_sort_newest": "Newest first",
|
||||
"conv_sort_oldest": "Oldest first",
|
||||
"conv_empty_heading": "Select two persons",
|
||||
"conv_empty_text": "The correspondence will be shown here.",
|
||||
"conv_no_results_heading": "No documents found.",
|
||||
"conv_no_results_text": "Try adjusting the time period.",
|
||||
"conv_swap_btn": "Swap persons",
|
||||
"conv_summary": "{count} documents · {yearFrom}–{yearTo}",
|
||||
"conv_new_doc_link": "New document in this correspondence",
|
||||
"admin_heading": "Admin Dashboard",
|
||||
"admin_tab_users": "Users",
|
||||
"admin_tab_groups": "Groups",
|
||||
"admin_tab_tags": "Tags",
|
||||
"admin_section_users": "User management",
|
||||
"admin_col_login": "Login",
|
||||
"admin_col_groups": "Groups",
|
||||
"admin_col_password": "Password",
|
||||
"admin_multiselect_hint": "Ctrl+Click to select",
|
||||
"admin_password_placeholder": "New PW (optional)",
|
||||
"admin_no_groups": "No groups",
|
||||
"admin_btn_delete_user_title": "Delete user",
|
||||
"admin_section_new_user": "Create new user",
|
||||
"admin_multiselect_hint_multi": "Ctrl+Click for multiple",
|
||||
"admin_multiselect_hint_full": "Ctrl+Click for multiple selection",
|
||||
"admin_section_tags": "Tags",
|
||||
"admin_tags_warning": "Warning: Renaming or deleting affects all linked documents.",
|
||||
"admin_btn_edit_tag_label": "Edit tag",
|
||||
"admin_tag_delete_confirm": "Really delete? The tag will be removed from all documents.",
|
||||
"admin_btn_delete_tag_label": "Delete tag",
|
||||
"admin_section_groups": "Group management",
|
||||
"admin_col_name": "Name",
|
||||
"admin_col_permissions": "Permissions",
|
||||
"admin_col_actions": "Actions",
|
||||
"admin_group_delete_confirm": "Really delete group?",
|
||||
"admin_section_new_group": "Create new group",
|
||||
"admin_group_name_placeholder": "Group name (e.g. Editors)",
|
||||
"admin_user_delete_confirm": "Really delete user {username}?",
|
||||
"doc_file_error_preview": "Could not load preview.",
|
||||
"doc_download_title": "Download",
|
||||
"doc_tag_filter_title": "Filter by {name}",
|
||||
"doc_conversation_title": "Show conversation",
|
||||
"doc_preview_iframe_title": "Document Preview",
|
||||
"doc_image_alt": "Original scan",
|
||||
"doc_no_date": "No date",
|
||||
"person_merge_will_be_deleted": "will be deleted.",
|
||||
"comp_typeahead_placeholder": "Type a name...",
|
||||
"comp_typeahead_loading": "Searching...",
|
||||
"comp_multiselect_placeholder": "Type a name...",
|
||||
"comp_multiselect_remove": "Remove",
|
||||
"comp_multiselect_loading": "Searching...",
|
||||
"comp_taginput_placeholder_create": "Add tags...",
|
||||
"comp_taginput_placeholder_filter": "Filter by tags...",
|
||||
"comp_taginput_remove": "Remove tag",
|
||||
"comp_taginput_create_hint": "Press Enter to create tag.",
|
||||
"error_email_already_in_use": "This email address is already used by another account.",
|
||||
"error_wrong_current_password": "The current password is incorrect.",
|
||||
"nav_profile": "Profile",
|
||||
"profile_heading": "My Profile",
|
||||
"profile_section_personal": "Personal Information",
|
||||
"profile_label_first_name": "First name",
|
||||
"profile_label_last_name": "Last name",
|
||||
"profile_label_birth_date": "Date of birth",
|
||||
"profile_label_email": "Email address",
|
||||
"profile_label_contact": "Contact details",
|
||||
"profile_contact_placeholder": "Phone, address or other notes...",
|
||||
"profile_section_password": "Change password",
|
||||
"profile_label_current_password": "Current password",
|
||||
"profile_label_new_password": "New password",
|
||||
"profile_label_new_password_confirm": "New password (repeat)",
|
||||
"profile_password_mismatch": "The new passwords do not match.",
|
||||
"profile_saved": "Saved.",
|
||||
"profile_password_changed": "Password changed successfully.",
|
||||
"user_profile_heading": "Profile of"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"hello_world": "Hello, {name} from es!",
|
||||
"error_document_not_found": "Documento no encontrado.",
|
||||
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
||||
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
||||
@@ -10,5 +9,190 @@
|
||||
"error_unauthorized": "No ha iniciado sesión.",
|
||||
"error_forbidden": "No tiene permiso para realizar esta acción.",
|
||||
"error_validation_error": "La entrada no es válida.",
|
||||
"error_internal_error": "Se ha producido un error inesperado."
|
||||
"error_internal_error": "Se ha producido un error inesperado.",
|
||||
"nav_documents": "Documentos",
|
||||
"nav_persons": "Personas",
|
||||
"nav_conversations": "Conversaciones",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Cerrar sesión",
|
||||
"btn_save": "Guardar",
|
||||
"btn_cancel": "Cancelar",
|
||||
"btn_edit": "Editar",
|
||||
"btn_create": "Crear",
|
||||
"btn_delete": "Eliminar",
|
||||
"btn_back_to_overview": "Volver al resumen",
|
||||
"btn_back": "Volver",
|
||||
"btn_back_to_document": "Volver al documento",
|
||||
"form_label_first_name": "Nombre",
|
||||
"form_label_last_name": "Apellido",
|
||||
"form_label_alias": "Apodo / Alias",
|
||||
"form_placeholder_alias": "p.ej. Abuela Frieda, Tío Karl…",
|
||||
"form_label_date": "Fecha",
|
||||
"form_placeholder_date": "DD.MM.AAAA",
|
||||
"form_date_error": "Introduzca en formato DD.MM.AAAA, p.ej. 20.12.2026",
|
||||
"form_label_location": "Lugar",
|
||||
"form_placeholder_location": "p.ej. Berlín, Viena…",
|
||||
"form_label_sender": "Remitente",
|
||||
"form_label_receivers": "Destinatarios",
|
||||
"form_label_title": "Título *",
|
||||
"form_label_tags": "Etiquetas",
|
||||
"form_label_content": "Contenido",
|
||||
"form_placeholder_content": "Breve descripción del contenido…",
|
||||
"form_label_transcription": "Transcripción",
|
||||
"form_placeholder_transcription": "Texto completo del documento…",
|
||||
"form_label_archive_location": "Ubicación de almacenamiento",
|
||||
"form_placeholder_archive_location": "p.ej. Armario 3, Carpeta B",
|
||||
"form_helper_archive_location": "¿Dónde se encuentra el documento original?",
|
||||
"login_heading": "Iniciar sesión",
|
||||
"login_label_username": "Usuario",
|
||||
"login_label_password": "Contraseña",
|
||||
"login_btn_submit": "Iniciar sesión",
|
||||
"docs_search_placeholder": "Buscar en título, contenido, lugar...",
|
||||
"docs_btn_filter": "Filtrar",
|
||||
"docs_btn_reset_title": "Restablecer filtro",
|
||||
"docs_filter_label_tags": "Etiquetas",
|
||||
"docs_filter_label_sender": "Remitente",
|
||||
"docs_filter_label_receivers": "Destinatarios",
|
||||
"docs_filter_label_from": "Desde",
|
||||
"docs_filter_label_to": "Hasta",
|
||||
"docs_btn_new": "Nuevo documento",
|
||||
"docs_empty_heading": "No se encontraron documentos",
|
||||
"docs_empty_text": "Intente ajustar los filtros o cambiar el término de búsqueda.",
|
||||
"docs_empty_btn_clear": "Borrar todos los filtros",
|
||||
"docs_list_from": "De",
|
||||
"docs_list_to": "Para",
|
||||
"docs_list_unknown": "Desconocido",
|
||||
"doc_section_who_when": "Quién & Cuándo",
|
||||
"doc_section_description": "Descripción",
|
||||
"doc_section_file": "Archivo",
|
||||
"doc_file_upload_label": "Subir archivo",
|
||||
"doc_file_upload_note": "(opcional)",
|
||||
"doc_file_replace_label": "Subir nuevo archivo",
|
||||
"doc_file_replace_note": "(reemplaza el archivo actual)",
|
||||
"doc_current_file_label": "Archivo actual:",
|
||||
"doc_new_heading": "Nuevo documento",
|
||||
"doc_edit_heading": "Editar",
|
||||
"doc_section_details": "Detalles",
|
||||
"doc_label_document_date": "Fecha del documento",
|
||||
"doc_label_creation_location": "Lugar de creación",
|
||||
"doc_label_archive_location_original": "Ubicación de almacenamiento (original)",
|
||||
"doc_section_persons": "Personas",
|
||||
"doc_sender_not_specified": "No especificado",
|
||||
"doc_no_receivers": "Sin destinatarios",
|
||||
"doc_section_content": "Contenido",
|
||||
"doc_label_summary": "Resumen",
|
||||
"doc_loading": "Cargando documento...",
|
||||
"doc_download_link": "Intentar descarga directa",
|
||||
"doc_no_scan": "No hay escaneo disponible",
|
||||
"persons_heading": "Directorio de personas",
|
||||
"persons_subtitle": "Explore el índice de todas las personas registradas en el archivo familiar.",
|
||||
"persons_btn_new": "Nueva persona",
|
||||
"persons_search_placeholder": "Buscar nombres...",
|
||||
"persons_empty_heading": "No se encontraron personas.",
|
||||
"persons_empty_text": "Pruebe con otro término de búsqueda.",
|
||||
"persons_new_heading": "Nueva persona",
|
||||
"persons_section_details": "Datos de la persona",
|
||||
"person_edit_heading": "Editar persona",
|
||||
"person_label_full_name": "Nombre completo",
|
||||
"person_merge_heading": "Fusionar persona",
|
||||
"person_merge_description": "Esta persona se fusionará con la persona de destino seleccionada. Todos los documentos y enlaces se transferirán y esta persona será eliminada.",
|
||||
"person_merge_target_label": "Fusionar con",
|
||||
"person_btn_merge": "Fusionar",
|
||||
"person_btn_merge_confirm": "Sí, fusionar",
|
||||
"person_merge_warning": "Atención: Esta acción no se puede deshacer.",
|
||||
"person_label_notes": "Notas",
|
||||
"person_placeholder_notes": "Notas biográficas, observaciones…",
|
||||
"person_label_birth_year": "Año de nacimiento",
|
||||
"person_label_death_year": "Año de fallecimiento",
|
||||
"person_placeholder_year": "p.ej. 1923",
|
||||
"person_year_error": "Introduzca un año de cuatro dígitos",
|
||||
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
|
||||
"person_docs_heading": "Documentos enviados",
|
||||
"person_no_docs": "Esta persona aún no está vinculada como remitente.",
|
||||
"person_received_docs_heading": "Documentos recibidos",
|
||||
"person_no_received_docs": "Esta persona aún no está vinculada como receptor.",
|
||||
"person_role_sender": "Enviado",
|
||||
"person_role_receiver": "Recibido",
|
||||
"person_co_correspondents_heading": "Corresponsales frecuentes",
|
||||
"person_show_more": "+ {count} más",
|
||||
"conv_heading": "Conversaciones",
|
||||
"conv_subtitle": "Siga la correspondencia entre dos personas cronológicamente.",
|
||||
"conv_label_person_a": "Persona A (Remitente)",
|
||||
"conv_label_person_b": "Persona B (Destinatario)",
|
||||
"conv_label_from": "Período desde",
|
||||
"conv_label_to": "Período hasta",
|
||||
"conv_sort_label": "Ordenar:",
|
||||
"conv_sort_newest": "Más reciente primero",
|
||||
"conv_sort_oldest": "Más antiguo primero",
|
||||
"conv_empty_heading": "Seleccione dos personas",
|
||||
"conv_empty_text": "La correspondencia se mostrará aquí.",
|
||||
"conv_no_results_heading": "No se encontraron documentos.",
|
||||
"conv_no_results_text": "Intente ajustar el período de tiempo.",
|
||||
"conv_swap_btn": "Intercambiar personas",
|
||||
"conv_summary": "{count} documentos · {yearFrom}–{yearTo}",
|
||||
"conv_new_doc_link": "Nuevo documento en esta correspondencia",
|
||||
"admin_heading": "Panel de administración",
|
||||
"admin_tab_users": "Usuarios",
|
||||
"admin_tab_groups": "Grupos",
|
||||
"admin_tab_tags": "Etiquetas",
|
||||
"admin_section_users": "Gestión de usuarios",
|
||||
"admin_col_login": "Login",
|
||||
"admin_col_groups": "Grupos",
|
||||
"admin_col_password": "Contraseña",
|
||||
"admin_multiselect_hint": "Ctrl+Clic para seleccionar",
|
||||
"admin_password_placeholder": "Nueva contraseña (opcional)",
|
||||
"admin_no_groups": "Sin grupos",
|
||||
"admin_btn_delete_user_title": "Eliminar usuario",
|
||||
"admin_section_new_user": "Crear nuevo usuario",
|
||||
"admin_multiselect_hint_multi": "Ctrl+Clic para varios",
|
||||
"admin_multiselect_hint_full": "Ctrl+Clic para selección múltiple",
|
||||
"admin_section_tags": "Etiquetas",
|
||||
"admin_tags_warning": "Advertencia: Renombrar o eliminar afecta a todos los documentos vinculados.",
|
||||
"admin_btn_edit_tag_label": "Editar etiqueta",
|
||||
"admin_tag_delete_confirm": "¿Realmente eliminar? La etiqueta se eliminará de todos los documentos.",
|
||||
"admin_btn_delete_tag_label": "Eliminar etiqueta",
|
||||
"admin_section_groups": "Gestión de grupos",
|
||||
"admin_col_name": "Nombre",
|
||||
"admin_col_permissions": "Permisos",
|
||||
"admin_col_actions": "Acciones",
|
||||
"admin_group_delete_confirm": "¿Realmente eliminar el grupo?",
|
||||
"admin_section_new_group": "Crear nuevo grupo",
|
||||
"admin_group_name_placeholder": "Nombre del grupo (p.ej. Editores)",
|
||||
"admin_user_delete_confirm": "¿Realmente eliminar al usuario {username}?",
|
||||
"doc_file_error_preview": "No se pudo cargar la vista previa.",
|
||||
"doc_download_title": "Descargar",
|
||||
"doc_tag_filter_title": "Filtrar por {name}",
|
||||
"doc_conversation_title": "Ver conversación",
|
||||
"doc_preview_iframe_title": "Vista previa del documento",
|
||||
"doc_image_alt": "Escaneado original",
|
||||
"doc_no_date": "Sin fecha",
|
||||
"person_merge_will_be_deleted": "será eliminado.",
|
||||
"comp_typeahead_placeholder": "Escriba un nombre...",
|
||||
"comp_typeahead_loading": "Buscando...",
|
||||
"comp_multiselect_placeholder": "Escriba un nombre...",
|
||||
"comp_multiselect_remove": "Eliminar",
|
||||
"comp_multiselect_loading": "Buscando...",
|
||||
"comp_taginput_placeholder_create": "Añadir etiquetas...",
|
||||
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
|
||||
"comp_taginput_remove": "Eliminar etiqueta",
|
||||
"comp_taginput_create_hint": "Pulse Enter para crear etiqueta.",
|
||||
"error_email_already_in_use": "Esta dirección de correo ya está en uso por otra cuenta.",
|
||||
"error_wrong_current_password": "La contraseña actual es incorrecta.",
|
||||
"nav_profile": "Perfil",
|
||||
"profile_heading": "Mi perfil",
|
||||
"profile_section_personal": "Información personal",
|
||||
"profile_label_first_name": "Nombre",
|
||||
"profile_label_last_name": "Apellido",
|
||||
"profile_label_birth_date": "Fecha de nacimiento",
|
||||
"profile_label_email": "Correo electrónico",
|
||||
"profile_label_contact": "Datos de contacto",
|
||||
"profile_contact_placeholder": "Teléfono, dirección u otras notas...",
|
||||
"profile_section_password": "Cambiar contraseña",
|
||||
"profile_label_current_password": "Contraseña actual",
|
||||
"profile_label_new_password": "Nueva contraseña",
|
||||
"profile_label_new_password_confirm": "Nueva contraseña (repetir)",
|
||||
"profile_password_mismatch": "Las nuevas contraseñas no coinciden.",
|
||||
"profile_saved": "Guardado.",
|
||||
"profile_password_changed": "Contraseña cambiada con éxito.",
|
||||
"user_profile_heading": "Perfil de"
|
||||
}
|
||||
|
||||
1883
frontend/package-lock.json
generated
1883
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,23 +7,26 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"prepare": "svelte-kit sync || true && git -C .. config core.hooksPath .husky 2>/dev/null || true",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"openapi-fetch": "^0.13.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openapi-typescript": "^7.8.0",
|
||||
"@eslint/compat": "^1.4.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@inlang/paraglide-js": "^2.5.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.48.5",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
@@ -36,6 +39,7 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.13.0",
|
||||
"globals": "^16.5.0",
|
||||
"openapi-typescript": "^7.8.0",
|
||||
"playwright": "^1.56.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
|
||||
49
frontend/playwright.config.ts
Normal file
49
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
|
||||
// Auto-starts the SvelteKit dev server before E2E tests.
|
||||
// Reuses the existing server if already running (e.g. during active development).
|
||||
// The backend + DB + MinIO must be started separately (see README or CI workflow).
|
||||
webServer: {
|
||||
command: 'npm run dev -- --port 3000',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000
|
||||
},
|
||||
fullyParallel: false, // tests share auth state → run sequentially within a worker
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
|
||||
locale: 'de-DE', // ensures Accept-Language: de is sent so locale detection defaults to German
|
||||
screenshot: 'on', // always capture screenshots
|
||||
video: 'retain-on-failure',
|
||||
trace: 'retain-on-failure'
|
||||
},
|
||||
|
||||
projects: [
|
||||
// 1. Auth setup: logs in and saves the session cookie to disk
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /auth\.setup\.ts/
|
||||
},
|
||||
// 2. All E2E tests, re-using the stored session
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: path.join(__dirname, 'e2e/.auth/user.json')
|
||||
},
|
||||
dependencies: ['setup']
|
||||
}
|
||||
],
|
||||
|
||||
outputDir: 'test-results/e2e'
|
||||
});
|
||||
@@ -7,10 +7,6 @@
|
||||
"plugin.inlang.messageFormat": {
|
||||
"pathPattern": "./messages/{locale}.json"
|
||||
},
|
||||
"baseLocale": "en",
|
||||
"locales": [
|
||||
"en",
|
||||
"es",
|
||||
"de"
|
||||
]
|
||||
"baseLocale": "de",
|
||||
"locales": ["de", "en", "es"]
|
||||
}
|
||||
|
||||
7
frontend/src/app.d.ts
vendored
7
frontend/src/app.d.ts
vendored
@@ -6,10 +6,17 @@ declare global {
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
birthDate?: string;
|
||||
email?: string;
|
||||
contact?: string;
|
||||
groups: {
|
||||
name: string;
|
||||
permissions: string[];
|
||||
}[];
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Locals {
|
||||
|
||||
@@ -2,65 +2,96 @@ import { redirect, type Handle, type HandleFetch } from '@sveltejs/kit';
|
||||
import { paraglideMiddleware } from '$lib/paraglide/server';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import { env } from 'process';
|
||||
import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime';
|
||||
import { detectLocale } from '$lib/server/locale';
|
||||
|
||||
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
|
||||
event.request = request;
|
||||
const PUBLIC_PATHS = ['/login', '/logout'];
|
||||
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
|
||||
const handleLocaleDetection: Handle = ({ event, resolve }) => {
|
||||
if (!event.cookies.get(cookieName)) {
|
||||
const locale = detectLocale(event.request.headers.get('accept-language') ?? '');
|
||||
if (locale) {
|
||||
event.cookies.set(cookieName, locale, {
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
maxAge: cookieMaxAge,
|
||||
httpOnly: false
|
||||
});
|
||||
}
|
||||
}
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
const handleAuth: Handle = async ({ event, resolve }) => {
|
||||
const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p));
|
||||
if (!isPublic && !event.locals.user) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
const handleParaglide: Handle = ({ event, resolve }) =>
|
||||
paraglideMiddleware(event.request, ({ request, locale }) => {
|
||||
event.request = request;
|
||||
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const userGroup: Handle = async ({ event, resolve }) => {
|
||||
const auth = event.cookies.get('auth_token');
|
||||
const auth = event.cookies.get('auth_token');
|
||||
|
||||
if (auth) {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/users/me', {
|
||||
headers: { Authorization: auth }
|
||||
if (auth) {
|
||||
try {
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const response = await fetch(`${apiUrl}/api/users/me`, {
|
||||
headers: { Authorization: auth }
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
event.locals.user = user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user in hook:', error);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
event.locals.user = user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user in hook:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
|
||||
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
||||
const isNotLoginTest = !request.url.includes('/api/users/me');
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
||||
|
||||
if (isApi && isNotLoginTest) {
|
||||
const token = event.cookies.get('auth_token');
|
||||
if (isApi) {
|
||||
// If the request already carries an explicit Authorization header (e.g. the
|
||||
// login action sends Basic auth), pass it through unchanged.
|
||||
if (request.headers.has('Authorization')) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
const token = event.cookies.get('auth_token');
|
||||
|
||||
// Clone the request first to preserve the body
|
||||
const clonedRequest = request.clone();
|
||||
if (!token) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// Create new request with Authorization header and preserved body
|
||||
const modifiedRequest = new Request(clonedRequest, {
|
||||
headers: {
|
||||
...Object.fromEntries(clonedRequest.headers),
|
||||
'Authorization': token
|
||||
}
|
||||
});
|
||||
// Clone the request first to preserve the body
|
||||
const clonedRequest = request.clone();
|
||||
|
||||
return fetch(modifiedRequest);
|
||||
}
|
||||
// Create new request with Authorization header and preserved body
|
||||
const modifiedRequest = new Request(clonedRequest, {
|
||||
headers: {
|
||||
...Object.fromEntries(clonedRequest.headers),
|
||||
Authorization: token
|
||||
}
|
||||
});
|
||||
|
||||
return fetch(request);
|
||||
return fetch(modifiedRequest);
|
||||
}
|
||||
|
||||
return fetch(request);
|
||||
};
|
||||
|
||||
export const handle = sequence(userGroup, 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';
|
||||
|
||||
export function createApiClient(fetch: typeof globalThis.fetch) {
|
||||
return createClient<paths>({
|
||||
baseUrl: env.API_INTERNAL_URL || 'http://localhost:8080',
|
||||
fetch
|
||||
});
|
||||
return createClient<paths>({
|
||||
baseUrl: env.API_INTERNAL_URL || 'http://localhost:8080',
|
||||
fetch
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,117 +1,143 @@
|
||||
<script lang="ts">
|
||||
type Person = { id?: string; firstName?: string; lastName?: string; alias?: string };
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
export let selectedPersons: Person[] = [];
|
||||
interface Props {
|
||||
selectedPersons?: Person[];
|
||||
}
|
||||
|
||||
let searchTerm = '';
|
||||
let results: Person[] = [];
|
||||
let showDropdown = false;
|
||||
let loading = false;
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
let inputEl: HTMLInputElement;
|
||||
let dropdownStyle = '';
|
||||
let { selectedPersons = $bindable([]) }: Props = $props();
|
||||
|
||||
function updateDropdownPosition() {
|
||||
if (!inputEl) return;
|
||||
const rect = inputEl.getBoundingClientRect();
|
||||
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
||||
}
|
||||
let searchTerm = $state('');
|
||||
let results: Person[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
let inputEl: HTMLInputElement;
|
||||
let dropdownStyle = $state('');
|
||||
|
||||
function handleInput() {
|
||||
showDropdown = true;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
if (searchTerm.length < 1) { results = []; return; }
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
|
||||
if (res.ok) {
|
||||
const all: Person[] = await res.json();
|
||||
results = all.filter(p => !selectedPersons.some(s => s.id === p.id));
|
||||
}
|
||||
} catch { results = []; }
|
||||
finally { loading = false; }
|
||||
}, 300);
|
||||
}
|
||||
function updateDropdownPosition() {
|
||||
if (!inputEl) return;
|
||||
const rect = inputEl.getBoundingClientRect();
|
||||
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
||||
}
|
||||
|
||||
function selectPerson(person: Person) {
|
||||
selectedPersons = [...selectedPersons, person];
|
||||
searchTerm = '';
|
||||
showDropdown = false;
|
||||
results = [];
|
||||
}
|
||||
function handleInput() {
|
||||
showDropdown = true;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
if (searchTerm.length < 1) {
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
|
||||
if (res.ok) {
|
||||
const all: Person[] = await res.json();
|
||||
results = all.filter((p) => !selectedPersons.some((s) => s.id === p.id));
|
||||
}
|
||||
} catch {
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function removePerson(id: string | undefined) {
|
||||
selectedPersons = selectedPersons.filter(p => p.id !== id);
|
||||
}
|
||||
function selectPerson(person: Person) {
|
||||
selectedPersons = [...selectedPersons, person];
|
||||
searchTerm = '';
|
||||
showDropdown = false;
|
||||
results = [];
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (node && !node.contains(e.target as Node) && !(e as Event).defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return { destroy() { document.removeEventListener('click', handleClick, true); } };
|
||||
}
|
||||
function removePerson(id: string | undefined) {
|
||||
selectedPersons = selectedPersons.filter((p) => p.id !== id);
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:scroll={updateDropdownPosition} on:resize={updateDropdownPosition} />
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
|
||||
{#each selectedPersons as person}
|
||||
<input type="hidden" name="receiverIds" value={person.id} />
|
||||
{#each selectedPersons as person (person.id)}
|
||||
<input type="hidden" name="receiverIds" value={person.id} />
|
||||
{/each}
|
||||
|
||||
<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">
|
||||
{#each selectedPersons as person}
|
||||
<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}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => removePerson(person.id)}
|
||||
class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5"
|
||||
aria-label="Entfernen"
|
||||
>
|
||||
<svg class="w-3 h-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}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
{#each selectedPersons as person (person.id)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-brand-sand/40 px-2 py-1 text-sm font-medium text-brand-navy"
|
||||
>
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removePerson(person.id)}
|
||||
class="ml-0.5 text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
||||
aria-label={m.comp_multiselect_remove()}
|
||||
>
|
||||
<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
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
on:input={handleInput}
|
||||
on:focus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||
placeholder={selectedPersons.length === 0 ? 'Namen tippen...' : ''}
|
||||
class="flex-1 min-w-[120px] border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
oninput={handleInput}
|
||||
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||
placeholder={selectedPersons.length === 0 ? m.comp_multiselect_placeholder() : ''}
|
||||
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="p-2 text-gray-500 text-sm">Suche...</div>
|
||||
{:else}
|
||||
{#each results as person}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-brand-sand/30 text-gray-900"
|
||||
on:click={() => selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{person.lastName}, {person.firstName}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
<div
|
||||
style={dropdownStyle}
|
||||
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}
|
||||
<div class="p-2 text-sm text-gray-500">{m.comp_multiselect_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person (person.id)}
|
||||
<div
|
||||
class="cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-brand-sand/30"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{person.lastName}, {person.firstName}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
185
frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts
Normal file
185
frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonMultiSelect from './PersonMultiSelect.svelte';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const PERSONS = [
|
||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' },
|
||||
{ id: '3', firstName: 'Karl', lastName: 'König' }
|
||||
];
|
||||
|
||||
function mockFetch(persons = PERSONS) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(persons)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function receiverInputs() {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>('input[type="hidden"][name="receiverIds"]')
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMultiSelect – rendering', () => {
|
||||
it('renders the text input with placeholder when no persons selected', async () => {
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
await expect.element(page.getByPlaceholder('Namen tippen...')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-multiselect-empty.png' });
|
||||
});
|
||||
|
||||
it('renders pre-selected persons as chips', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
]
|
||||
});
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-multiselect-with-chips.png' });
|
||||
});
|
||||
|
||||
it('renders hidden inputs for each selected person', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
]
|
||||
});
|
||||
await tick();
|
||||
const inputs = receiverInputs();
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0].value).toBe('1');
|
||||
expect(inputs[1].value).toBe('2');
|
||||
});
|
||||
|
||||
it('hides the placeholder when persons are selected', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
|
||||
});
|
||||
await expect.element(page.getByPlaceholder('Namen tippen...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Selecting persons ────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMultiSelect – selecting persons', () => {
|
||||
it('adds a person chip on result click', async () => {
|
||||
mockFetch();
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
await expect.element(input).toHaveValue('');
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/person-multiselect-one-selected.png'
|
||||
});
|
||||
});
|
||||
|
||||
it('can select multiple persons sequentially', async () => {
|
||||
mockFetch();
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
const input = page.getByRole('textbox');
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Musterfrau, Anna').click();
|
||||
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/person-multiselect-two-selected.png'
|
||||
});
|
||||
});
|
||||
|
||||
it('filters already-selected persons from search results', async () => {
|
||||
mockFetch();
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
|
||||
});
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects a result with Enter key', async () => {
|
||||
mockFetch([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Removing persons ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMultiSelect – removing persons', () => {
|
||||
it('removes a chip when its × button is clicked', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
]
|
||||
});
|
||||
// Buttons have aria-label="Entfernen"
|
||||
const removeButtons = page.getByRole('button', { name: 'Entfernen' });
|
||||
await removeButtons.first().click();
|
||||
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes the corresponding hidden input when a chip is removed', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
]
|
||||
});
|
||||
await page.getByRole('button', { name: 'Entfernen' }).first().click();
|
||||
await tick();
|
||||
const inputs = receiverInputs();
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs[0].value).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Click outside ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMultiSelect – click outside', () => {
|
||||
it('closes the dropdown when clicking outside', async () => {
|
||||
mockFetch();
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
||||
document.body.click();
|
||||
await tick();
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,131 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
// Props
|
||||
export let name: string;
|
||||
export let label: string;
|
||||
export let value: string = "";
|
||||
export let initialName: string = "";
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
initialName?: string;
|
||||
restrictToCorrespondentsOf?: string;
|
||||
onchange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let {
|
||||
name,
|
||||
label,
|
||||
value = $bindable(''),
|
||||
initialName = '',
|
||||
restrictToCorrespondentsOf,
|
||||
onchange
|
||||
}: Props = $props();
|
||||
|
||||
// Lokaler State
|
||||
let searchTerm = initialName;
|
||||
let searchTerm = $state(initialName);
|
||||
|
||||
// Sync mit externen Änderungen (z.B. Reset Button)
|
||||
$: searchTerm = initialName;
|
||||
let results: Person[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
let results: any[] = [];
|
||||
let showDropdown = false;
|
||||
let loading = false;
|
||||
let debounceTimer: any;
|
||||
function handleInput() {
|
||||
if (value && searchTerm !== initialName) {
|
||||
value = '';
|
||||
onchange?.('');
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
// Wenn der User tippt, ist die alte ID ungültig -> Reset
|
||||
if (value && searchTerm !== initialName) {
|
||||
value = "";
|
||||
dispatch('change', { value: "" }); // Bescheid geben: Auswahl aufgehoben
|
||||
}
|
||||
showDropdown = true;
|
||||
clearTimeout(debounceTimer);
|
||||
|
||||
showDropdown = true;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
const term = untrack(() => searchTerm);
|
||||
const correspondentsOf = untrack(() => restrictToCorrespondentsOf);
|
||||
loading = true;
|
||||
try {
|
||||
let url: string;
|
||||
if (correspondentsOf) {
|
||||
if (term.length >= 1) {
|
||||
url = `/api/persons/${correspondentsOf}/correspondents?q=${encodeURIComponent(term)}`;
|
||||
} else {
|
||||
url = `/api/persons/${correspondentsOf}/correspondents`;
|
||||
}
|
||||
} else {
|
||||
if (term.length < 1) {
|
||||
results = [];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
url = `/api/persons?q=${encodeURIComponent(term)}`;
|
||||
}
|
||||
const res = await fetch(url);
|
||||
results = res.ok ? await res.json() : [];
|
||||
} catch (e) {
|
||||
console.error('Suche fehlgeschlagen', e);
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(async () => {
|
||||
if (searchTerm.length < 1) {
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
|
||||
if (res.ok) {
|
||||
results = await res.json();
|
||||
} else {
|
||||
results = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Suche fehlgeschlagen", e);
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
function handleFocus() {
|
||||
showDropdown = true;
|
||||
if (restrictToCorrespondentsOf) {
|
||||
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
||||
loading = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/persons/${personId}/correspondents`);
|
||||
results = res.ok ? await res.json() : [];
|
||||
} catch (e) {
|
||||
console.error('Suche fehlgeschlagen', e);
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
function selectPerson(person: any) {
|
||||
value = person.id;
|
||||
searchTerm = `${person.firstName} ${person.lastName}`;
|
||||
showDropdown = false;
|
||||
function selectPerson(person: Person) {
|
||||
value = person.id!;
|
||||
searchTerm = `${person.firstName} ${person.lastName}`;
|
||||
showDropdown = false;
|
||||
onchange?.(person.id!);
|
||||
}
|
||||
|
||||
// --- NEU: Event feuern ---
|
||||
dispatch('change', { value: person.id });
|
||||
}
|
||||
|
||||
let inputEl: HTMLInputElement;
|
||||
let dropdownStyle = '';
|
||||
|
||||
function updateDropdownPosition() {
|
||||
if (!inputEl) return;
|
||||
const rect = inputEl.getBoundingClientRect();
|
||||
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: any) => {
|
||||
if (node && !node.contains(event.target) && !event.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() { document.removeEventListener('click', handleClick, true); }
|
||||
};
|
||||
}
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:scroll={updateDropdownPosition} on:resize={updateDropdownPosition} />
|
||||
|
||||
<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
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
id="{name}-search"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
on:input={handleInput}
|
||||
on:focus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||
placeholder="Namen tippen..."
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="{name}-search"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
oninput={handleInput}
|
||||
onfocus={handleFocus}
|
||||
placeholder={m.comp_typeahead_placeholder()}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="p-2 text-gray-500 text-sm">Suche...</div>
|
||||
{:else}
|
||||
{#each results as person}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-blue-100 text-gray-900"
|
||||
on:click={() => selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="font-medium block truncate">
|
||||
{person.lastName}, {person.firstName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
<div
|
||||
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="p-2 text-sm text-gray-500">{m.comp_typeahead_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person (person.id)}
|
||||
<div
|
||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-blue-100"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="block truncate font-medium">
|
||||
{person.lastName}, {person.firstName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
267
frontend/src/lib/components/PersonTypeahead.svelte.spec.ts
Normal file
267
frontend/src/lib/components/PersonTypeahead.svelte.spec.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonTypeahead from './PersonTypeahead.svelte';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const PERSONS = [
|
||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
];
|
||||
|
||||
function mockFetchWithPersons(persons = PERSONS) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(persons)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchFailure() {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||
}
|
||||
|
||||
function hiddenInput(name: string) {
|
||||
return document.querySelector<HTMLInputElement>(`input[type="hidden"][name="${name}"]`);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – rendering', () => {
|
||||
it('renders the label and text input', async () => {
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
await expect.element(page.getByText('Absender')).toBeInTheDocument();
|
||||
await expect.element(page.getByPlaceholder('Namen tippen...')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-empty.png' });
|
||||
});
|
||||
|
||||
it('pre-fills the visible input from initialName', async () => {
|
||||
render(PersonTypeahead, {
|
||||
name: 'senderId',
|
||||
label: 'Absender',
|
||||
initialName: 'Max Mustermann'
|
||||
});
|
||||
// The $effect that syncs initialName runs after mount — poll until the value appears
|
||||
await expect.element(page.getByPlaceholder('Namen tippen...')).toHaveValue('Max Mustermann');
|
||||
});
|
||||
|
||||
it('renders a hidden input with the correct name attribute', async () => {
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hidden input starts with the provided value', async () => {
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender', value: '42' });
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')?.value).toBe('42');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Search ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – search', () => {
|
||||
it('opens the dropdown with results after typing', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-open.png' });
|
||||
});
|
||||
|
||||
it('shows loading indicator while fetching', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Suche...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no dropdown when the search returns empty results', async () => {
|
||||
mockFetchWithPersons([]);
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('XYZ');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Suche...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no results when fetch fails', async () => {
|
||||
mockFetchFailure();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Selection ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – selection', () => {
|
||||
it('fills the visible input and closes dropdown on click', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Mustermann, Max' }))
|
||||
.not.toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
||||
});
|
||||
|
||||
it('sets the hidden input value to the selected person id', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')?.value).toBe('1');
|
||||
});
|
||||
|
||||
it('calls onchange with the person id on selection', async () => {
|
||||
mockFetchWithPersons();
|
||||
const onchange = vi.fn();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender', onchange });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('selects a result with Enter key', async () => {
|
||||
mockFetchWithPersons([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Clearing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – clearing a selection', () => {
|
||||
it('clears the hidden value when user edits the visible input after a selection', async () => {
|
||||
mockFetchWithPersons();
|
||||
const onchange = vi.fn();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender', onchange });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
onchange.mockClear();
|
||||
|
||||
await input.fill('x');
|
||||
await waitForDebounce();
|
||||
expect(onchange).toHaveBeenCalledWith('');
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')?.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Correspondent mode ───────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – correspondent mode', () => {
|
||||
it('fetches correspondents immediately on focus when restrictToCorrespondentsOf is set', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, {
|
||||
name: 'receiverId',
|
||||
label: 'Empfänger',
|
||||
restrictToCorrespondentsOf: 'person-a-id'
|
||||
});
|
||||
|
||||
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
|
||||
await waitForDebounce();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/persons/person-a-id/correspondents')
|
||||
);
|
||||
});
|
||||
|
||||
it('shows correspondent results immediately on focus without typing', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, {
|
||||
name: 'receiverId',
|
||||
label: 'Empfänger',
|
||||
restrictToCorrespondentsOf: 'person-a-id'
|
||||
});
|
||||
|
||||
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
|
||||
await waitForDebounce();
|
||||
|
||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses correspondents endpoint with q param when typing', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, {
|
||||
name: 'receiverId',
|
||||
label: 'Empfänger',
|
||||
restrictToCorrespondentsOf: 'person-a-id'
|
||||
});
|
||||
|
||||
await page.getByPlaceholder('Namen tippen...').fill('Anna');
|
||||
await waitForDebounce();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/persons/person-a-id/correspondents?q=Anna')
|
||||
);
|
||||
});
|
||||
|
||||
it('uses normal persons endpoint when restrictToCorrespondentsOf is not set', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
|
||||
await page.getByPlaceholder('Namen tippen...').fill('Anna');
|
||||
await waitForDebounce();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Anna'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Click outside ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – click outside', () => {
|
||||
it('closes the dropdown when clicking outside the component', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
||||
document.body.click();
|
||||
await tick();
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,89 +1,105 @@
|
||||
<script lang="ts">
|
||||
export let tags: string[] = []; // Two-way binding
|
||||
export let allowCreation = true;
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let inputVal = '';
|
||||
let suggestions: string[] = [];
|
||||
let activeIndex = -1;
|
||||
let showSuggestions = false;
|
||||
interface Props {
|
||||
tags?: string[];
|
||||
allowCreation?: boolean;
|
||||
}
|
||||
|
||||
// Fetch suggestions from backend
|
||||
async function fetchSuggestions(query: string) {
|
||||
if (query.length < 2) {
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// API returns Tag objects with { id, name }
|
||||
const names: string[] = data.map((t: { name: string }) => t.name);
|
||||
suggestions = names.filter((t) => !tags.includes(t));
|
||||
showSuggestions = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Tag fetch error", e);
|
||||
}
|
||||
}
|
||||
let { tags = $bindable([]), allowCreation = true }: Props = $props();
|
||||
|
||||
function addTag(tag: string) {
|
||||
const trimmed = tag.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
tags = [...tags, trimmed];
|
||||
}
|
||||
inputVal = '';
|
||||
suggestions = [];
|
||||
showSuggestions = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
let inputVal = $state('');
|
||||
let suggestions: string[] = $state([]);
|
||||
let activeIndex = $state(-1);
|
||||
let showSuggestions = $state(false);
|
||||
|
||||
function removeTag(index: number) {
|
||||
tags = tags.filter((_, i) => i !== index);
|
||||
}
|
||||
async function fetchSuggestions(query: string) {
|
||||
if (query.length < 2) {
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const names: string[] = data.map((t: { name: string }) => t.name);
|
||||
const currentTags = untrack(() => tags);
|
||||
suggestions = names.filter((t) => !currentTags.includes(t));
|
||||
showSuggestions = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Tag fetch error', e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && suggestions[activeIndex]) {
|
||||
addTag(suggestions[activeIndex]);
|
||||
} else if(allowCreation) {
|
||||
addTag(inputVal); // Add new tag
|
||||
}
|
||||
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
|
||||
removeTag(tags.length - 1);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % suggestions.length;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
||||
}
|
||||
}
|
||||
function addTag(tag: string) {
|
||||
const trimmed = tag.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
tags = [...tags, trimmed];
|
||||
}
|
||||
inputVal = '';
|
||||
suggestions = [];
|
||||
showSuggestions = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
fetchSuggestions(inputVal);
|
||||
}
|
||||
function removeTag(index: number) {
|
||||
tags = tags.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && suggestions[activeIndex]) {
|
||||
addTag(suggestions[activeIndex]);
|
||||
} else if (allowCreation) {
|
||||
addTag(inputVal);
|
||||
}
|
||||
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
|
||||
removeTag(tags.length - 1);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % suggestions.length;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
||||
}
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||
showSuggestions = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="w-full" use:clickOutside>
|
||||
<!-- Tag Container -->
|
||||
<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 -->
|
||||
{#each tags as tag, i}
|
||||
{#each tags as tag, i (i)}
|
||||
<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}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => removeTag(i)}
|
||||
aria-label="Schlagwort entfernen"
|
||||
onclick={() => removeTag(i)}
|
||||
aria-label={m.comp_taginput_remove()}
|
||||
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
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -96,37 +112,36 @@
|
||||
{/each}
|
||||
|
||||
<!-- Input Field -->
|
||||
<div class="relative flex-1 min-w-[120px]">
|
||||
<div class="relative min-w-[120px] flex-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={inputVal}
|
||||
on:input={handleInput}
|
||||
on:keydown={handleKeydown}
|
||||
on:focus={() => handleInput()}
|
||||
on:blur={() => setTimeout(() => (showSuggestions = false), 200)}
|
||||
oninput={() => fetchSuggestions(inputVal)}
|
||||
onkeydown={handleKeydown}
|
||||
onfocus={() => fetchSuggestions(inputVal)}
|
||||
placeholder={tags.length === 0
|
||||
? allowCreation
|
||||
? 'Schlagworte hinzufügen...'
|
||||
: 'Nach Schlagworten filtern...'
|
||||
: ''}
|
||||
class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
|
||||
? allowCreation
|
||||
? m.comp_taginput_placeholder_create()
|
||||
: m.comp_taginput_placeholder_filter()
|
||||
: ''}
|
||||
class="h-full w-full border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||
/>
|
||||
|
||||
<!-- Typeahead Dropdown -->
|
||||
{#if showSuggestions && suggestions.length > 0}
|
||||
<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
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
tabindex="0"
|
||||
class="px-3 py-2 text-sm cursor-pointer hover:bg-brand-sand/20 {i === activeIndex
|
||||
? 'bg-brand-sand/20 text-brand-navy font-bold'
|
||||
: 'text-gray-700'}"
|
||||
on:click={() => addTag(suggestion)}
|
||||
on:keydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-brand-sand/20 {i === activeIndex
|
||||
? 'bg-brand-sand/20 font-bold text-brand-navy'
|
||||
: 'text-gray-700'}"
|
||||
onclick={() => addTag(suggestion)}
|
||||
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</li>
|
||||
@@ -136,6 +151,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{#if allowCreation}
|
||||
<p class="text-xs text-gray-400 mt-1">Enter drücken um Schlagwort zu erstellen.</p>
|
||||
<p class="mt-1 text-xs text-gray-400">{m.comp_taginput_create_hint()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
210
frontend/src/lib/components/TagInput.svelte.spec.ts
Normal file
210
frontend/src/lib/components/TagInput.svelte.spec.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import TagInput from './TagInput.svelte';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
function mockFetchWithTags(tagNames: string[]) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(tagNames.map((name) => ({ name })))
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchEmpty() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagInput – rendering', () => {
|
||||
it('shows creation placeholder when allowCreation=true and no tags', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
await expect.element(page.getByPlaceholder('Schlagworte hinzufügen...')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-empty.png' });
|
||||
});
|
||||
|
||||
it('shows filter placeholder when allowCreation=false', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: false });
|
||||
await expect.element(page.getByPlaceholder('Nach Schlagworten filtern...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders existing tags as chips', async () => {
|
||||
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-with-chips.png' });
|
||||
});
|
||||
|
||||
it('hides input placeholder once tags exist', async () => {
|
||||
render(TagInput, { tags: ['Familie'], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await expect.element(input).toHaveAttribute('placeholder', '');
|
||||
});
|
||||
|
||||
it('shows the "Enter" hint when allowCreation=true', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
await expect.element(page.getByText(/Enter drücken/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the "Enter" hint when allowCreation=false', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: false });
|
||||
await expect.element(page.getByText(/Enter drücken/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Adding tags ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagInput – adding tags', () => {
|
||||
it('adds a tag on Enter and clears the input', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Urlaubsreise');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(page.getByText('Urlaubsreise')).toBeInTheDocument();
|
||||
await expect.element(input).toHaveValue('');
|
||||
});
|
||||
|
||||
it('trims whitespace from the new tag', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill(' Leerzeichen ');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(page.getByText('Leerzeichen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not add a duplicate tag', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: ['Familie'], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Familie');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(input).toHaveValue('');
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not add an arbitrary tag when allowCreation=false', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: [], allowCreation: false });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('UnbekannterTag');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(page.getByText('UnbekannterTag')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Removing tags ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagInput – removing tags', () => {
|
||||
it('removes a chip when its × button is clicked', async () => {
|
||||
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
|
||||
// The × buttons have aria-label="Schlagwort entfernen"
|
||||
document.querySelector<HTMLElement>('button[aria-label="Schlagwort entfernen"]')!.click();
|
||||
await tick();
|
||||
await expect.element(page.getByText('Familie')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-after-remove.png' });
|
||||
});
|
||||
|
||||
it('removes the last tag on Backspace when the input is empty', async () => {
|
||||
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
|
||||
(document.querySelector('input[type="text"]') as HTMLInputElement).focus();
|
||||
await userEvent.keyboard('{Backspace}');
|
||||
await expect.element(page.getByText('Krieg')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not remove a tag on Backspace when the input has text', async () => {
|
||||
render(TagInput, { tags: ['Familie'], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('x');
|
||||
await userEvent.keyboard('{Backspace}');
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Autocomplete ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagInput – autocomplete', () => {
|
||||
it('shows suggestions after typing 2+ characters', async () => {
|
||||
mockFetchWithTags(['Familie', 'Freunde']);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fa');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestions.png' });
|
||||
});
|
||||
|
||||
it('does not call fetch for fewer than 2 characters', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('F');
|
||||
await waitForDebounce();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters already-selected tags out of suggestions', async () => {
|
||||
mockFetchWithTags(['Familie', 'Freunde']);
|
||||
render(TagInput, { tags: ['Familie'], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fr');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
|
||||
await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects a suggestion on click and adds it as a chip', async () => {
|
||||
mockFetchWithTags(['Familie', 'Freunde']);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fa');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
await expect.element(input).toHaveValue('');
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestion-selected.png' });
|
||||
});
|
||||
|
||||
it('navigates suggestions with ArrowDown and selects with Enter', async () => {
|
||||
mockFetchWithTags(['Aachen', 'Berlin', 'Celle']);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('__');
|
||||
await waitForDebounce();
|
||||
await userEvent.keyboard('{ArrowDown}'); // index 0 → Aachen
|
||||
await userEvent.keyboard('{ArrowDown}'); // index 1 → Berlin
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the dropdown when clicking outside the component', async () => {
|
||||
mockFetchWithTags(['Familie']);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fa');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument();
|
||||
document.body.click();
|
||||
await tick();
|
||||
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,20 +5,22 @@ import * as m from '$lib/paraglide/messages.js';
|
||||
* Keep in sync with backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
|
||||
*/
|
||||
export type ErrorCode =
|
||||
| 'DOCUMENT_NOT_FOUND'
|
||||
| 'DOCUMENT_NO_FILE'
|
||||
| 'FILE_NOT_FOUND'
|
||||
| 'FILE_UPLOAD_FAILED'
|
||||
| 'USER_NOT_FOUND'
|
||||
| 'IMPORT_ALREADY_RUNNING'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'INTERNAL_ERROR';
|
||||
| 'DOCUMENT_NOT_FOUND'
|
||||
| 'DOCUMENT_NO_FILE'
|
||||
| 'FILE_NOT_FOUND'
|
||||
| 'FILE_UPLOAD_FAILED'
|
||||
| 'USER_NOT_FOUND'
|
||||
| 'EMAIL_ALREADY_IN_USE'
|
||||
| 'WRONG_CURRENT_PASSWORD'
|
||||
| 'IMPORT_ALREADY_RUNNING'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'INTERNAL_ERROR';
|
||||
|
||||
export interface BackendError {
|
||||
code: ErrorCode;
|
||||
message: string; // English developer message — not shown to users
|
||||
code: ErrorCode;
|
||||
message: string; // English developer message — not shown to users
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,29 +28,43 @@ export interface BackendError {
|
||||
* Returns null if the body is not valid JSON or does not contain a code field.
|
||||
*/
|
||||
export async function parseBackendError(res: Response): Promise<BackendError | null> {
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && typeof body.code === 'string') {
|
||||
return body as BackendError;
|
||||
}
|
||||
} catch {
|
||||
// Body was not JSON
|
||||
}
|
||||
return null;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && typeof body.code === 'string') {
|
||||
return body as BackendError;
|
||||
}
|
||||
} catch {
|
||||
// Body was not JSON
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns a localised message for the given error code via Paraglide. Falls back to INTERNAL_ERROR. */
|
||||
export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
switch (code) {
|
||||
case 'DOCUMENT_NOT_FOUND': return m.error_document_not_found();
|
||||
case 'DOCUMENT_NO_FILE': return m.error_document_no_file();
|
||||
case 'FILE_NOT_FOUND': return m.error_file_not_found();
|
||||
case 'FILE_UPLOAD_FAILED': return m.error_file_upload_failed();
|
||||
case 'USER_NOT_FOUND': return m.error_user_not_found();
|
||||
case 'IMPORT_ALREADY_RUNNING':return m.error_import_already_running();
|
||||
case 'UNAUTHORIZED': return m.error_unauthorized();
|
||||
case 'FORBIDDEN': return m.error_forbidden();
|
||||
case 'VALIDATION_ERROR': return m.error_validation_error();
|
||||
default: return m.error_internal_error();
|
||||
}
|
||||
switch (code) {
|
||||
case 'DOCUMENT_NOT_FOUND':
|
||||
return m.error_document_not_found();
|
||||
case 'DOCUMENT_NO_FILE':
|
||||
return m.error_document_no_file();
|
||||
case 'FILE_NOT_FOUND':
|
||||
return m.error_file_not_found();
|
||||
case 'FILE_UPLOAD_FAILED':
|
||||
return m.error_file_upload_failed();
|
||||
case 'USER_NOT_FOUND':
|
||||
return m.error_user_not_found();
|
||||
case 'EMAIL_ALREADY_IN_USE':
|
||||
return m.error_email_already_in_use();
|
||||
case 'WRONG_CURRENT_PASSWORD':
|
||||
return m.error_wrong_current_password();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,22 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/users/me": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getCurrentUser"];
|
||||
put: operations["updateProfile"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tags/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -68,6 +84,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/me/password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["changePassword"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getPersons"];
|
||||
put?: never;
|
||||
post: operations["createPerson"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons/{id}/merge": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -148,17 +196,17 @@ export interface paths {
|
||||
patch: operations["updateGroup"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/me": {
|
||||
"/api/users/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getCurrentUser"];
|
||||
get: operations["getUser"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
delete: operations["deleteUser"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
@@ -180,14 +228,14 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons": {
|
||||
"/api/persons/{id}/received-documents": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getPersons"];
|
||||
get: operations["getPersonReceivedDocuments"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
@@ -212,6 +260,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons/{id}/correspondents": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getCorrespondents"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/file": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -276,37 +340,66 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["deleteUser"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
UpdateProfileDTO: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: date */
|
||||
birthDate?: string;
|
||||
email?: string;
|
||||
contact?: string;
|
||||
};
|
||||
AppUser: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
username: string;
|
||||
password?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: date */
|
||||
birthDate?: string;
|
||||
email?: string;
|
||||
contact?: string;
|
||||
enabled: boolean;
|
||||
groups: components["schemas"]["UserGroup"][];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
};
|
||||
UserGroup: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
};
|
||||
Tag: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
PersonUpdateDTO: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
alias?: string;
|
||||
notes?: string;
|
||||
/** Format: int32 */
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
};
|
||||
Person: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
alias?: string;
|
||||
notes?: string;
|
||||
/** Format: int32 */
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
};
|
||||
DocumentUpdateDTO: {
|
||||
title?: string;
|
||||
@@ -352,22 +445,9 @@ export interface components {
|
||||
initialPassword?: string;
|
||||
groupIds?: string[];
|
||||
};
|
||||
AppUser: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
username: string;
|
||||
password?: string;
|
||||
email?: string;
|
||||
enabled: boolean;
|
||||
groups: components["schemas"]["UserGroup"][];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
};
|
||||
UserGroup: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
ChangePasswordDTO: {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
};
|
||||
GroupDTO: {
|
||||
name?: string;
|
||||
@@ -391,6 +471,50 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
getCurrentUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["AppUser"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
updateProfile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdateProfileDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["AppUser"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
updateTag: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -472,9 +596,7 @@ export interface operations {
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
"application/json": components["schemas"]["PersonUpdateDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
@@ -581,6 +703,76 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
changePassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChangePasswordDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
getPersons: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Person"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
createPerson: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Person"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
mergePerson: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -741,11 +933,13 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getCurrentUser: {
|
||||
getUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
@@ -761,6 +955,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
searchTags: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -783,13 +997,13 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getPersons: {
|
||||
getPersonReceivedDocuments: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
};
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
@@ -800,7 +1014,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Person"][];
|
||||
"*/*": components["schemas"]["Document"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -827,6 +1041,30 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getCorrespondents: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Person"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getDocumentFile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -922,24 +1160,4 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
32
frontend/src/lib/server/locale.spec.ts
Normal file
32
frontend/src/lib/server/locale.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { detectLocale } from './locale';
|
||||
|
||||
describe('detectLocale', () => {
|
||||
it('returns de for a German browser', () => {
|
||||
expect(detectLocale('de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7')).toBe('de');
|
||||
});
|
||||
|
||||
it('returns en for an English browser', () => {
|
||||
expect(detectLocale('en-US,en;q=0.9')).toBe('en');
|
||||
});
|
||||
|
||||
it('returns es for a Spanish browser', () => {
|
||||
expect(detectLocale('es-MX,es;q=0.9,en-US;q=0.8')).toBe('es');
|
||||
});
|
||||
|
||||
it('falls back to a supported language when the primary is unsupported', () => {
|
||||
expect(detectLocale('fr-FR,fr;q=0.9,en;q=0.8')).toBe('en');
|
||||
});
|
||||
|
||||
it('respects quality values — picks the highest-priority supported locale', () => {
|
||||
expect(detectLocale('en-US;q=0.7,de-DE;q=0.9')).toBe('de');
|
||||
});
|
||||
|
||||
it('returns null for a completely unsupported language', () => {
|
||||
expect(detectLocale('ja-JP,ja;q=0.9,zh-CN;q=0.8')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an empty header', () => {
|
||||
expect(detectLocale('')).toBeNull();
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/server/locale.ts
Normal file
20
frontend/src/lib/server/locale.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { locales } from '$lib/paraglide/runtime';
|
||||
|
||||
/**
|
||||
* Picks the best supported locale from an Accept-Language header value.
|
||||
* Returns null when no supported locale is found.
|
||||
*/
|
||||
export function detectLocale(acceptLanguage: string): string | null {
|
||||
const preferred = acceptLanguage
|
||||
.split(',')
|
||||
.map((part) => {
|
||||
const [lang, q] = part.trim().split(';q=');
|
||||
return { lang: lang.trim().split('-')[0].toLowerCase(), q: q ? parseFloat(q) : 1 };
|
||||
})
|
||||
.sort((a, b) => b.q - a.q);
|
||||
|
||||
for (const { lang } of preferred) {
|
||||
if ((locales as readonly string[]).includes(lang)) return lang;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
80
frontend/src/lib/utils.spec.ts
Normal file
80
frontend/src/lib/utils.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { germanToIso, isoToGerman } from './utils';
|
||||
|
||||
describe('isoToGerman', () => {
|
||||
it('converts a standard ISO date', () => {
|
||||
expect(isoToGerman('2024-03-15')).toBe('15.03.2024');
|
||||
});
|
||||
|
||||
it('preserves leading zeros for day and month', () => {
|
||||
expect(isoToGerman('2024-01-05')).toBe('05.01.2024');
|
||||
});
|
||||
|
||||
it('handles December 31', () => {
|
||||
expect(isoToGerman('1945-12-31')).toBe('31.12.1945');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(isoToGerman('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for plain text', () => {
|
||||
expect(isoToGerman('not-a-date')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for partial ISO string', () => {
|
||||
expect(isoToGerman('2024-03')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for ISO with time component', () => {
|
||||
expect(isoToGerman('2024-03-15T12:00:00')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('germanToIso', () => {
|
||||
it('converts a standard German date', () => {
|
||||
expect(germanToIso('15.03.2024')).toBe('2024-03-15');
|
||||
});
|
||||
|
||||
it('preserves leading zeros for day and month', () => {
|
||||
expect(germanToIso('05.01.2024')).toBe('2024-01-05');
|
||||
});
|
||||
|
||||
it('handles December 31', () => {
|
||||
expect(germanToIso('31.12.1945')).toBe('1945-12-31');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(germanToIso('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for plain text', () => {
|
||||
expect(germanToIso('not-a-date')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for date without leading zeros', () => {
|
||||
expect(germanToIso('5.3.2024')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for ISO format input', () => {
|
||||
expect(germanToIso('2024-03-15')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for partial German date', () => {
|
||||
expect(germanToIso('15.03')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip conversion', () => {
|
||||
const dates = ['2024-03-15', '1945-01-01', '2000-12-31', '1899-07-04'];
|
||||
|
||||
for (const date of dates) {
|
||||
it(`ISO → German → ISO is identity for ${date}`, () => {
|
||||
expect(germanToIso(isoToGerman(date))).toBe(date);
|
||||
});
|
||||
}
|
||||
|
||||
it('German → ISO → German is identity', () => {
|
||||
expect(isoToGerman(germanToIso('20.04.1889'))).toBe('20.04.1889');
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/utils.ts
Normal file
20
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Converts an ISO date string (YYYY-MM-DD) to German display format (DD.MM.YYYY).
|
||||
* Returns an empty string for invalid or empty input.
|
||||
*/
|
||||
export function isoToGerman(iso: string): string {
|
||||
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
|
||||
const [y, m, d] = iso.split('-');
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a German date string (DD.MM.YYYY) to ISO format (YYYY-MM-DD).
|
||||
* Returns an empty string for invalid or empty input.
|
||||
*/
|
||||
export function germanToIso(german: string): string {
|
||||
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) return '';
|
||||
const [, d, m, y] = match;
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
11
frontend/src/lib/utils/date.ts
Normal file
11
frontend/src/lib/utils/date.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Format an ISO date string (YYYY-MM-DD) for display.
|
||||
* Uses T12:00:00 to avoid UTC timezone off-by-one when converting to local time.
|
||||
*/
|
||||
export function formatDate(isoDate: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(new Date(isoDate + 'T12:00:00'));
|
||||
}
|
||||
44
frontend/src/lib/utils/sort.spec.ts
Normal file
44
frontend/src/lib/utils/sort.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sortDocumentsByDate } from './sort';
|
||||
|
||||
const doc = (id: string, documentDate: string | null) =>
|
||||
({ id, documentDate }) as { id: string; documentDate: string | null };
|
||||
|
||||
describe('sortDocumentsByDate', () => {
|
||||
it('sorts DESC by default — newest first', () => {
|
||||
const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')];
|
||||
const result = sortDocumentsByDate(docs, 'DESC');
|
||||
expect(result.map((d) => d.id)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
it('sorts ASC — oldest first', () => {
|
||||
const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')];
|
||||
const result = sortDocumentsByDate(docs, 'ASC');
|
||||
expect(result.map((d) => d.id)).toEqual(['a', 'c', 'b']);
|
||||
});
|
||||
|
||||
it('places documents without a date last in DESC', () => {
|
||||
const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)];
|
||||
const result = sortDocumentsByDate(docs, 'DESC');
|
||||
expect(result[0].id).toBe('b');
|
||||
expect(result.slice(1).map((d) => d.id)).toContain('a');
|
||||
expect(result.slice(1).map((d) => d.id)).toContain('c');
|
||||
});
|
||||
|
||||
it('places documents without a date last in ASC', () => {
|
||||
const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)];
|
||||
const result = sortDocumentsByDate(docs, 'ASC');
|
||||
expect(result[0].id).toBe('b');
|
||||
});
|
||||
|
||||
it('does not mutate the original array', () => {
|
||||
const docs = [doc('a', '1950-01-01'), doc('b', '1920-01-01')];
|
||||
const original = [...docs];
|
||||
sortDocumentsByDate(docs, 'ASC');
|
||||
expect(docs).toEqual(original);
|
||||
});
|
||||
|
||||
it('returns an empty array unchanged', () => {
|
||||
expect(sortDocumentsByDate([], 'DESC')).toEqual([]);
|
||||
});
|
||||
});
|
||||
19
frontend/src/lib/utils/sort.ts
Normal file
19
frontend/src/lib/utils/sort.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type SortDir = 'ASC' | 'DESC';
|
||||
|
||||
/**
|
||||
* Returns a new array of documents sorted by documentDate.
|
||||
* Documents without a date are always placed last, regardless of direction.
|
||||
*/
|
||||
export function sortDocumentsByDate<T extends { documentDate?: string | null }>(
|
||||
docs: T[],
|
||||
dir: SortDir
|
||||
): T[] {
|
||||
return [...docs].sort((a, b) => {
|
||||
const da = a.documentDate ?? '';
|
||||
const db = b.documentDate ?? '';
|
||||
if (!da && !db) return 0;
|
||||
if (!da) return 1;
|
||||
if (!db) return -1;
|
||||
return dir === 'DESC' ? db.localeCompare(da) : da.localeCompare(db);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user
|
||||
};
|
||||
};
|
||||
return {
|
||||
user: locals.user,
|
||||
canWrite:
|
||||
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,110 +1,189 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/state';
|
||||
$: user = page.data.user;
|
||||
$: isAdmin = user?.groups.some(g => g.permissions.includes("ADMIN"))
|
||||
import './layout.css';
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
const locales = ['DE', 'EN', 'ES'] as const;
|
||||
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
|
||||
const activeLocale = $derived(getLocale().toUpperCase());
|
||||
|
||||
const isAdmin = $derived(
|
||||
data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'))
|
||||
);
|
||||
|
||||
// Set after client-side hydration completes. Used by E2E tests to know the
|
||||
// page is interactive (event handlers registered) before they interact with it.
|
||||
let hydrated = $state(false);
|
||||
onMount(() => {
|
||||
hydrated = true;
|
||||
});
|
||||
|
||||
let userMenuOpen = $state(false);
|
||||
|
||||
const userInitials = $derived.by(() => {
|
||||
const first = data?.user?.firstName?.[0];
|
||||
const last = data?.user?.lastName?.[0];
|
||||
if (first && last) return (first + last).toUpperCase();
|
||||
return null;
|
||||
});
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
userMenuOpen = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-brand-sand">
|
||||
<!-- Changed background to Sand -->
|
||||
|
||||
<!-- Corporate Header -->
|
||||
<div class="min-h-screen bg-white" data-hydrated={hydrated || undefined}>
|
||||
{#if !page.url.pathname.startsWith('/login')}
|
||||
<header class="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-20">
|
||||
<!-- Slightly taller header -->
|
||||
<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>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 justify-between">
|
||||
<!-- Logo & Nav -->
|
||||
<div class="flex">
|
||||
<!-- LOGO (Extracted from their SVG) -->
|
||||
<div class="flex-shrink-0 flex items-center mr-8">
|
||||
<a href="/" class="flex items-center gap-2" aria-label="Familienarchiv">
|
||||
<!-- SVG Code from their site -->
|
||||
<svg
|
||||
width="250"
|
||||
height="25"
|
||||
viewBox="0 0 250 25"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<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
|
||||
>
|
||||
<path
|
||||
d="M0.156128 1.01562C5.375 1.43431 9.621 4.65591 9.621 11.6669V19.8779H0.156128V10.4467H5.76852C5.76852 5.6736 3.70661 3.72129 0.156128 2.8334V1.01562Z"
|
||||
fill="#B4B9FF"
|
||||
></path>
|
||||
<path
|
||||
d="M10.5892 19.8779C15.8076 19.4592 20.0541 16.2371 20.0541 9.22655V1.01562H10.5892V10.4467H16.2012C16.2012 15.2199 14.1397 17.1722 10.5892 18.0601V19.8779Z"
|
||||
fill="#B4B9FF"
|
||||
></path>
|
||||
|
||||
<text
|
||||
x="35"
|
||||
y="20"
|
||||
fill="#002850"
|
||||
font-family="Montserrat"
|
||||
font-weight="bold"
|
||||
font-size="20">FAMILIENARCHIV</text
|
||||
>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Nav Links (Montserrat font, Uppercase style often used in corporate) -->
|
||||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8 items-center">
|
||||
<nav class="hidden items-center sm:flex sm:space-x-1">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
|
||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||
? 'border-brand-navy text-brand-navy'
|
||||
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
Dokumente
|
||||
{m.nav_documents()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/persons"
|
||||
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
|
||||
{page.url.pathname.startsWith('/persons')
|
||||
? 'border-brand-navy text-brand-navy'
|
||||
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
|
||||
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')
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
Personen
|
||||
{m.nav_persons()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/conversations"
|
||||
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
|
||||
{page.url.pathname.startsWith('/conversations')
|
||||
? 'border-brand-navy text-brand-navy'
|
||||
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
|
||||
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')
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
Konversationen
|
||||
{m.nav_conversations()}
|
||||
</a>
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
|
||||
{page.url.pathname.startsWith('/admin')
|
||||
? 'border-brand-navy text-brand-navy'
|
||||
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
|
||||
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')
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
Admin
|
||||
{m.nav_admin()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Right Side -->
|
||||
<div class="flex items-center">
|
||||
<form action="/logout" method="POST" use:enhance>
|
||||
<button
|
||||
type="submit"
|
||||
class="text-sm text-gray-500 hover:text-brand-navy font-bold uppercase font-sans tracking-wide px-3 py-2 transition"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Language selector -->
|
||||
<div class="flex items-center gap-1 border-r border-gray-200 pr-3">
|
||||
{#each locales as locale (locale)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLocale(localeMap[locale])}
|
||||
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
|
||||
{activeLocale === locale
|
||||
? 'font-bold text-brand-navy'
|
||||
: 'font-normal text-gray-400 hover:text-brand-navy'}"
|
||||
>
|
||||
{locale}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- User menu -->
|
||||
<div
|
||||
class="relative"
|
||||
{@attach clickOutside}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') userMenuOpen = false; }}
|
||||
role="none"
|
||||
>
|
||||
{#if userInitials}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="true"
|
||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-sans text-xs font-bold text-white transition-opacity hover:opacity-80"
|
||||
>
|
||||
{userInitials}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.nav_profile()}
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="true"
|
||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||
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="h-4 w-4 opacity-50"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if userMenuOpen}
|
||||
<div
|
||||
class="absolute top-full right-0 z-50 mt-1 min-w-[10rem] rounded-sm border border-brand-sand bg-white shadow-md"
|
||||
>
|
||||
<a
|
||||
href="/profile"
|
||||
onclick={() => (userMenuOpen = false)}
|
||||
class="block px-4 py-2.5 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
|
||||
>
|
||||
{m.nav_profile()}
|
||||
</a>
|
||||
<div class="border-t border-brand-sand">
|
||||
<form action="/logout" method="POST" use:enhance>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full px-4 py-2.5 text-left font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
|
||||
>
|
||||
{m.nav_logout()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,6 +191,6 @@
|
||||
{/if}
|
||||
|
||||
<main class="py-6">
|
||||
<slot />
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -2,59 +2,60 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
const q = url.searchParams.get('q') || '';
|
||||
const from = url.searchParams.get('from') || '';
|
||||
const to = url.searchParams.get('to') || '';
|
||||
const senderId = url.searchParams.get('senderId') || '';
|
||||
const receiverId = url.searchParams.get('receiverId') || '';
|
||||
const tags = url.searchParams.getAll('tag');
|
||||
const q = url.searchParams.get('q') || '';
|
||||
const from = url.searchParams.get('from') || '';
|
||||
const to = url.searchParams.get('to') || '';
|
||||
const senderId = url.searchParams.get('senderId') || '';
|
||||
const receiverId = url.searchParams.get('receiverId') || '';
|
||||
const tags = url.searchParams.getAll('tag');
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
try {
|
||||
const [docsResult, personsResult] = await Promise.all([
|
||||
api.GET('/api/documents/search', {
|
||||
params: {
|
||||
query: {
|
||||
q: q || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
senderId: senderId || undefined,
|
||||
receiverId: receiverId || undefined,
|
||||
tag: tags.length ? tags : undefined
|
||||
}
|
||||
}
|
||||
}),
|
||||
api.GET('/api/persons')
|
||||
]);
|
||||
try {
|
||||
const [docsResult, personsResult] = await Promise.all([
|
||||
api.GET('/api/documents/search', {
|
||||
params: {
|
||||
query: {
|
||||
q: q || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
senderId: senderId || undefined,
|
||||
receiverId: receiverId || undefined,
|
||||
tag: tags.length ? tags : undefined
|
||||
}
|
||||
}
|
||||
}),
|
||||
api.GET('/api/persons')
|
||||
]);
|
||||
|
||||
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
const documents = docsResult.data ?? [];
|
||||
const allPersons: { id: string; firstName: string; lastName: string }[] = personsResult.data ?? [];
|
||||
const documents = docsResult.data ?? [];
|
||||
const allPersons: { id: string; firstName: string; lastName: string }[] =
|
||||
personsResult.data ?? [];
|
||||
|
||||
const senderObj = allPersons.find(p => p.id === senderId);
|
||||
const receiverObj = allPersons.find(p => p.id === receiverId);
|
||||
const senderObj = allPersons.find((p) => p.id === senderId);
|
||||
const receiverObj = allPersons.find((p) => p.id === receiverId);
|
||||
|
||||
return {
|
||||
documents,
|
||||
initialValues: {
|
||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
||||
},
|
||||
filters: { q, from, to, senderId, receiverId, tags },
|
||||
error: null as string | null
|
||||
};
|
||||
} catch (e) {
|
||||
if ((e as { status?: number }).status) throw e;
|
||||
console.error('Error loading data:', e);
|
||||
return {
|
||||
documents: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
filters: { q, from, to, senderId, receiverId, tags },
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
};
|
||||
}
|
||||
return {
|
||||
documents,
|
||||
initialValues: {
|
||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
||||
},
|
||||
filters: { q, from, to, senderId, receiverId, tags },
|
||||
error: null as string | null
|
||||
};
|
||||
} catch (e) {
|
||||
if ((e as { status?: number }).status) throw e;
|
||||
console.error('Error loading data:', e);
|
||||
return {
|
||||
documents: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
filters: { q, from, to, senderId, receiverId, tags },
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,24 +3,34 @@ import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import TagInput from '$lib/components/TagInput.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
|
||||
export let data;
|
||||
let { data } = $props();
|
||||
|
||||
// Local state variables
|
||||
let q = data.filters?.q || '';
|
||||
let from = data.filters?.from || '';
|
||||
let to = data.filters?.to || '';
|
||||
let senderId = data.filters?.senderId || '';
|
||||
let receiverId = data.filters?.receiverId || '';
|
||||
let tagNames = data.filters?.tags || [];
|
||||
let q = $state(untrack(() => data.filters?.q || ''));
|
||||
let qFocused = $state(false);
|
||||
let from = $state(untrack(() => data.filters?.from || ''));
|
||||
let to = $state(untrack(() => data.filters?.to || ''));
|
||||
let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
||||
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
||||
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
|
||||
|
||||
// Debounce Timer
|
||||
let searchTimer: any;
|
||||
let searchTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
let showAdvanced = false;
|
||||
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
||||
(filters?.tags?.length ?? 0) > 0 ||
|
||||
!!filters?.senderId ||
|
||||
!!filters?.receiverId ||
|
||||
!!filters?.from ||
|
||||
!!filters?.to;
|
||||
|
||||
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
|
||||
|
||||
function triggerSearch() {
|
||||
const params = new URLSearchParams();
|
||||
const params = new SvelteURLSearchParams();
|
||||
|
||||
if (q) params.set('q', q);
|
||||
if (from) params.set('from', from);
|
||||
@@ -42,27 +52,27 @@ function handleTextSearch() {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
let previousTags = tagNames.join(',');
|
||||
$: {
|
||||
const currentTags = tagNames.join(',');
|
||||
if (currentTags !== previousTags) {
|
||||
previousTags = currentTags;
|
||||
// Trigger search when tags change
|
||||
let prevTagStr = untrack(() => tagNames.join(','));
|
||||
$effect(() => {
|
||||
const cur = tagNames.join(',');
|
||||
if (cur !== prevTagStr) {
|
||||
prevTagStr = cur;
|
||||
triggerSearch();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function toggleAdvanced() {
|
||||
showAdvanced = !showAdvanced;
|
||||
}
|
||||
|
||||
// Sync with server data (e.g. after reset)
|
||||
$: {
|
||||
q = data.filters?.q || '';
|
||||
// Sync local state with server data after navigation.
|
||||
// Guard q: skip overwrite while the user is actively typing in the search field.
|
||||
$effect(() => {
|
||||
if (!qFocused) q = data.filters?.q || '';
|
||||
from = data.filters?.from || '';
|
||||
to = data.filters?.to || '';
|
||||
senderId = data.filters?.senderId || '';
|
||||
receiverId = data.filters?.receiverId || '';
|
||||
}
|
||||
tagNames = data.filters?.tags || [];
|
||||
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Outer Container: Matches the 'Sand' background of the layout -->
|
||||
@@ -76,59 +86,48 @@ $: {
|
||||
<input
|
||||
type="text"
|
||||
bind:value={q}
|
||||
on:input={handleTextSearch}
|
||||
placeholder="Suche in Titel, Inhalt, Ort..."
|
||||
oninput={handleTextSearch}
|
||||
onfocus={() => (qFocused = true)}
|
||||
onblur={() => (qFocused = false)}
|
||||
placeholder={m.docs_search_placeholder()}
|
||||
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">
|
||||
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/></svg
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Toggle Advanced Button -->
|
||||
<button
|
||||
on:click={toggleAdvanced}
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 transform transition-transform duration-200 {showAdvanced
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
Filter
|
||||
<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()}
|
||||
</button>
|
||||
|
||||
<!-- Reset Button -->
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center justify-center border border-transparent px-3 py-2.5 text-gray-400 transition hover:text-red-500"
|
||||
title="Filter zurücksetzen"
|
||||
title={m.docs_btn_reset_title()}
|
||||
>
|
||||
<svg class="h-5 w-5" 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
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Close-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 opacity-40"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -141,9 +140,9 @@ $: {
|
||||
<!-- Tag Filter -->
|
||||
<div class="md:col-span-12">
|
||||
<p class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase">
|
||||
Schlagworte
|
||||
{m.docs_filter_label_tags()}
|
||||
</p>
|
||||
<TagInput bind:tags={tagNames} allowCreation={false} on:change={triggerSearch} />
|
||||
<TagInput bind:tags={tagNames} allowCreation={false} />
|
||||
</div>
|
||||
|
||||
<!-- Sender -->
|
||||
@@ -153,10 +152,10 @@ $: {
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
label="Absender"
|
||||
label={m.docs_filter_label_sender()}
|
||||
bind:value={senderId}
|
||||
initialName={data.initialValues?.senderName}
|
||||
on:change={triggerSearch}
|
||||
onchange={triggerSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,10 +167,10 @@ $: {
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="receiverId"
|
||||
label="Empfänger"
|
||||
label={m.docs_filter_label_receivers()}
|
||||
bind:value={receiverId}
|
||||
initialName={data.initialValues?.receiverName}
|
||||
on:change={triggerSearch}
|
||||
onchange={triggerSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,13 +181,13 @@ $: {
|
||||
<label
|
||||
for="from"
|
||||
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||
>Von</label
|
||||
>{m.docs_filter_label_from()}</label
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
id="from"
|
||||
bind:value={from}
|
||||
on:change={triggerSearch}
|
||||
onchange={triggerSearch}
|
||||
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -196,13 +195,13 @@ $: {
|
||||
<label
|
||||
for="to"
|
||||
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||
>Bis</label
|
||||
>{m.docs_filter_label_to()}</label
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
id="to"
|
||||
bind:value={to}
|
||||
on:change={triggerSearch}
|
||||
onchange={triggerSearch}
|
||||
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -213,15 +212,20 @@ $: {
|
||||
|
||||
<!-- DOCUMENT LIST HEADER -->
|
||||
<div class="mb-2 flex justify-end">
|
||||
<a
|
||||
href="/documents/new"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neues Dokument
|
||||
</a>
|
||||
{#if data.canWrite}
|
||||
<a
|
||||
href="/documents/new"
|
||||
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"
|
||||
/>
|
||||
{m.docs_btn_new()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- DOCUMENT LIST -->
|
||||
@@ -232,7 +236,7 @@ $: {
|
||||
</div>
|
||||
{:else if data.documents && data.documents.length > 0}
|
||||
<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">
|
||||
<!-- LINK TO DETAIL PAGE -->
|
||||
<a href="/documents/{doc.id}" class="block p-6">
|
||||
@@ -261,39 +265,22 @@ $: {
|
||||
<!-- Metadata Row -->
|
||||
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
class="mr-1.5 h-4 w-4 text-brand-mint"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/></svg
|
||||
>
|
||||
{doc.documentDate ? new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(doc.documentDate + 'T12:00:00')) : '—'}
|
||||
<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) : '—'}
|
||||
</div>
|
||||
{#if doc.location}
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
class="mr-1.5 h-4 w-4 text-brand-mint"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/></svg
|
||||
>
|
||||
<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}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -304,40 +291,39 @@ $: {
|
||||
<div class="flex items-baseline">
|
||||
<span
|
||||
class="w-10 font-sans text-xs font-bold tracking-wide text-gray-400 uppercase"
|
||||
>Von</span
|
||||
>{m.docs_list_from()}</span
|
||||
>
|
||||
{#if doc.sender}
|
||||
<span class="text-gray-900"
|
||||
>{doc.sender.firstName} {doc.sender.lastName}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-gray-400 italic">Unbekannt</span>
|
||||
<span class="text-gray-400 italic">{m.docs_list_unknown()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-baseline">
|
||||
<span
|
||||
class="w-10 font-sans text-xs font-bold tracking-wide text-gray-400 uppercase"
|
||||
>An</span
|
||||
>{m.docs_list_to()}</span
|
||||
>
|
||||
{#if doc.receivers && doc.receivers.length > 0}
|
||||
<span class="text-gray-900">
|
||||
{doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-gray-400 italic">Unbekannt</span>
|
||||
<span class="text-gray-400 italic">{m.docs_list_unknown()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NEW: Tags Display -->
|
||||
<!-- Tags Display -->
|
||||
{#if doc.tags && doc.tags.length > 0}
|
||||
<div class="mt-4 flex flex-wrap gap-2 pt-3">
|
||||
{#each doc.tags as tag}
|
||||
{#each doc.tags as tag (tag.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 inline-flex 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"
|
||||
on:click|preventDefault|stopPropagation={() =>
|
||||
goto(`/?tag=${encodeURIComponent(tag.name)}`)}
|
||||
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"
|
||||
onclick={(e) => { e.preventDefault(); e.stopPropagation(); goto(`/?tag=${encodeURIComponent(tag.name)}`); }}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
@@ -350,14 +336,12 @@ $: {
|
||||
<div
|
||||
class="hidden items-center text-gray-300 transition-colors group-hover:text-brand-mint sm:flex"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<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>
|
||||
</a>
|
||||
@@ -370,24 +354,22 @@ $: {
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-sand/30"
|
||||
>
|
||||
<svg class="h-6 w-6 text-brand-navy" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/></svg
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="font-serif text-lg font-medium text-brand-navy">Keine Dokumente gefunden</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">
|
||||
Versuchen Sie, die Filter anzupassen oder den Suchbegriff zu ändern.
|
||||
{m.docs_empty_text()}
|
||||
</p>
|
||||
<button
|
||||
on:click={() => goto('/')}
|
||||
onclick={() => goto('/')}
|
||||
class="mt-6 text-sm font-bold tracking-wide text-brand-mint uppercase transition hover:text-brand-navy"
|
||||
>
|
||||
Alle Filter löschen
|
||||
{m.docs_empty_btn_clear()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -2,146 +2,130 @@ import { error, fail } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
type ApiResult = { response: Response; error?: unknown };
|
||||
|
||||
function toActionResult(result: ApiResult) {
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as { code?: string } | undefined)?.code;
|
||||
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function load({ fetch, locals }) {
|
||||
const user = locals.user;
|
||||
const hasAdmin = user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'));
|
||||
if (!hasAdmin) throw error(403, getErrorMessage('FORBIDDEN'));
|
||||
const user = locals.user;
|
||||
const hasAdmin = user?.groups?.some((g: { permissions: string[] }) =>
|
||||
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([
|
||||
api.GET('/api/users'),
|
||||
api.GET('/api/groups'),
|
||||
api.GET('/api/tags')
|
||||
]);
|
||||
const [usersResult, groupsResult, tagsResult] = await Promise.all([
|
||||
api.GET('/api/users'),
|
||||
api.GET('/api/groups'),
|
||||
api.GET('/api/tags')
|
||||
]);
|
||||
|
||||
return {
|
||||
users: usersResult.data ?? [],
|
||||
groups: groupsResult.data ?? [],
|
||||
tags: tagsResult.data ?? []
|
||||
};
|
||||
return {
|
||||
users: usersResult.data ?? [],
|
||||
groups: groupsResult.data ?? [],
|
||||
tags: tagsResult.data ?? []
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
createUser: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const api = createApiClient(fetch);
|
||||
createUser: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const result = await api.POST('/api/users', {
|
||||
body: {
|
||||
username: data.get('username') as string,
|
||||
initialPassword: data.get('password') as string,
|
||||
groupIds: data.getAll('groupIds') as string[]
|
||||
}
|
||||
});
|
||||
const result = await api.POST('/api/users', {
|
||||
body: {
|
||||
username: data.get('username') as string,
|
||||
initialPassword: data.get('password') as string,
|
||||
groupIds: data.getAll('groupIds') as string[]
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
return toActionResult(result);
|
||||
},
|
||||
|
||||
deleteUser: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
deleteUser: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const result = await api.DELETE('/api/users/{id}', {
|
||||
params: { path: { id } }
|
||||
});
|
||||
const result = await api.DELETE('/api/users/{id}', {
|
||||
params: { path: { id } }
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
return toActionResult(result);
|
||||
},
|
||||
|
||||
updateTag: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
updateTag: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const result = await api.PUT('/api/tags/{id}', {
|
||||
params: { path: { id } },
|
||||
body: { name: data.get('name') as string }
|
||||
});
|
||||
const result = await api.PUT('/api/tags/{id}', {
|
||||
params: { path: { id } },
|
||||
body: { name: data.get('name') as string }
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
return toActionResult(result);
|
||||
},
|
||||
|
||||
deleteTag: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
deleteTag: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const result = await api.DELETE('/api/tags/{id}', {
|
||||
params: { path: { id } }
|
||||
});
|
||||
const result = await api.DELETE('/api/tags/{id}', {
|
||||
params: { path: { id } }
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
return toActionResult(result);
|
||||
},
|
||||
|
||||
createGroup: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const api = createApiClient(fetch);
|
||||
createGroup: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const result = await api.POST('/api/groups', {
|
||||
body: {
|
||||
name: data.get('name') as string,
|
||||
permissions: data.getAll('permissions') as string[]
|
||||
}
|
||||
});
|
||||
const result = await api.POST('/api/groups', {
|
||||
body: {
|
||||
name: data.get('name') as string,
|
||||
permissions: data.getAll('permissions') as string[]
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
return toActionResult(result);
|
||||
},
|
||||
|
||||
updateGroup: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
updateGroup: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const result = await api.PATCH('/api/groups/{id}', {
|
||||
params: { path: { id } },
|
||||
body: {
|
||||
name: data.get('name') as string,
|
||||
permissions: data.getAll('permissions') as string[]
|
||||
}
|
||||
});
|
||||
const result = await api.PATCH('/api/groups/{id}', {
|
||||
params: { path: { id } },
|
||||
body: {
|
||||
name: data.get('name') as string,
|
||||
permissions: data.getAll('permissions') as string[]
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
return toActionResult(result);
|
||||
},
|
||||
|
||||
deleteGroup: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
deleteGroup: async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const result = await api.DELETE('/api/groups/{id}', {
|
||||
params: { path: { id } }
|
||||
});
|
||||
const result = await api.DELETE('/api/groups/{id}', {
|
||||
params: { path: { id } }
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
return toActionResult(result);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,112 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { enhance } from '$app/forms';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
export let data;
|
||||
export let form;
|
||||
let { data, form } = $props();
|
||||
|
||||
let activeTab = 'users';
|
||||
let editingTagId: string | null = null;
|
||||
let editingTagName = '';
|
||||
let editingUserId: string | null = null;
|
||||
let activeTab = $state('users');
|
||||
let editingTagId: string | null = $state(null);
|
||||
let editingTagName = $state('');
|
||||
let editingUserId: string | null = $state(null);
|
||||
let editingGroupId: string | null = $state(null);
|
||||
|
||||
function startEditTag(tag: any) {
|
||||
editingTagId = tag.id;
|
||||
editingTagName = tag.name;
|
||||
}
|
||||
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
||||
|
||||
function cancelEditTag() {
|
||||
editingTagId = null;
|
||||
editingTagName = '';
|
||||
}
|
||||
function startEditTag(tag: { id: string; name: string }) {
|
||||
editingTagId = tag.id;
|
||||
editingTagName = tag.name;
|
||||
}
|
||||
|
||||
function startEditUser(id: string) {
|
||||
editingUserId = id;
|
||||
}
|
||||
function cancelEditTag() {
|
||||
editingTagId = null;
|
||||
editingTagName = '';
|
||||
}
|
||||
|
||||
function cancelEditUser() {
|
||||
editingUserId = null;
|
||||
}
|
||||
function startEditUser(id: string) {
|
||||
editingUserId = id;
|
||||
}
|
||||
|
||||
let editingGroupId: string | null = null;
|
||||
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
||||
function cancelEditUser() {
|
||||
editingUserId = null;
|
||||
}
|
||||
|
||||
function startEditGroup(id: string) {
|
||||
editingGroupId = id;
|
||||
}
|
||||
function startEditGroup(id: string) {
|
||||
editingGroupId = id;
|
||||
}
|
||||
|
||||
function cancelEditGroup() {
|
||||
editingGroupId = null;
|
||||
}
|
||||
function cancelEditGroup() {
|
||||
editingGroupId = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-7xl mx-auto py-8 sm:px-6 lg:px-8 font-sans">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-serif text-brand-navy">Admin Dashboard</h1>
|
||||
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<h1 class="font-serif text-3xl text-brand-navy">{m.admin_heading()}</h1>
|
||||
|
||||
<!-- 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
|
||||
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'
|
||||
? 'bg-brand-navy text-white'
|
||||
: 'text-gray-500 hover:text-brand-navy'}"
|
||||
on:click={() => (activeTab = 'users')}>Benutzer</button
|
||||
onclick={() => (activeTab = 'users')}>{m.admin_tab_users()}</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'
|
||||
? 'bg-brand-navy text-white'
|
||||
: 'text-gray-500 hover:text-brand-navy'}"
|
||||
on:click={() => (activeTab = 'groups')}>Gruppen</button
|
||||
onclick={() => (activeTab = 'groups')}>{m.admin_tab_groups()}</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'
|
||||
? 'bg-brand-navy text-white'
|
||||
: 'text-gray-500 hover:text-brand-navy'}"
|
||||
on:click={() => (activeTab = 'tags')}>Schlagworte</button
|
||||
onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeTab === 'users'}
|
||||
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
|
||||
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
|
||||
<h2 class="text-lg font-bold text-gray-700">Benutzerverwaltung</h2>
|
||||
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
|
||||
>Login</th
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_login()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
|
||||
>Gruppen</th
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_groups()}</th
|
||||
>
|
||||
{#if editingUserId}
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider"
|
||||
>Passwort</th
|
||||
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_password()}</th
|
||||
>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each data.users as user}
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each data.users as user (user.id)}
|
||||
<tr class="group/row hover:bg-gray-50">
|
||||
{#if editingUserId === user.id}
|
||||
<!-- === 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}
|
||||
<!-- Hidden ID Input for the form -->
|
||||
<input
|
||||
type="hidden"
|
||||
name="username"
|
||||
@@ -116,27 +115,25 @@
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<!-- Groups Select -->
|
||||
<select
|
||||
name="groupIds"
|
||||
multiple
|
||||
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
|
||||
value={group.id}
|
||||
selected={user.groups.some((g) => g.id === group.id)}
|
||||
selected={user.groups.some((g: { id: string }) => g.id === group.id)}
|
||||
>
|
||||
{group.name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-[10px] text-gray-400 mt-1">Strg+Klick für Auswahl</p>
|
||||
<p class="mt-1 text-[10px] text-gray-400">{m.admin_multiselect_hint()}</p>
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right align-top">
|
||||
<!-- Password & Buttons -->
|
||||
<td class="px-6 py-4 text-right align-top whitespace-nowrap">
|
||||
<form
|
||||
id="edit-form-{user.id}"
|
||||
method="POST"
|
||||
@@ -151,63 +148,61 @@
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Neues PW (optional)"
|
||||
class="w-32 py-1 px-2 text-xs border border-brand-mint rounded"
|
||||
placeholder={m.admin_password_placeholder()}
|
||||
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
|
||||
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"
|
||||
>
|
||||
Speichern
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={cancelEditUser}
|
||||
class="bg-gray-200 text-gray-600 px-2 py-1 rounded text-xs font-bold uppercase hover:bg-gray-300"
|
||||
onclick={cancelEditUser}
|
||||
class="rounded bg-gray-200 px-2 py-1 text-xs font-bold text-gray-600 uppercase hover:bg-gray-300"
|
||||
>
|
||||
Abbrechen
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
{:else}
|
||||
<!-- === 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}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if user.groups && user.groups.length > 0}
|
||||
{#each user.groups as group}
|
||||
{#each user.groups as group (group.id)}
|
||||
<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}
|
||||
</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-xs text-gray-400 italic">Keine Gruppen</span>
|
||||
<span class="text-xs text-gray-400 italic">{m.admin_no_groups()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</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">
|
||||
<!-- Edit Button -->
|
||||
<button
|
||||
on:click={() => startEditUser(user.id)}
|
||||
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
|
||||
onclick={() => startEditUser(user.id)}
|
||||
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
|
||||
>
|
||||
Bearbeiten
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
|
||||
<!-- Delete Button -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteUser"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm(`Benutzer '${user.username}' wirklich löschen?`)) {
|
||||
if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) {
|
||||
cancel();
|
||||
}
|
||||
return async ({ update }) => {
|
||||
@@ -218,10 +213,10 @@
|
||||
>
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
<button
|
||||
class="text-gray-300 hover:text-red-600 transition-colors p-1"
|
||||
title="Benutzer löschen"
|
||||
class="p-1 text-gray-300 transition-colors hover:text-red-600"
|
||||
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
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -240,68 +235,66 @@
|
||||
</table>
|
||||
|
||||
<!-- Create User Form -->
|
||||
<div class="p-6 bg-gray-50 border-t border-gray-200">
|
||||
<h3 class="text-xs font-bold uppercase text-gray-500 mb-4 tracking-wide">
|
||||
Neuen Benutzer anlegen
|
||||
<div class="border-t border-gray-200 bg-gray-50 p-6">
|
||||
<h3 class="mb-4 text-xs font-bold tracking-wide text-gray-500 uppercase">
|
||||
{m.admin_section_new_user()}
|
||||
</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/createUser"
|
||||
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
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder="Login"
|
||||
required
|
||||
class="rounded border-gray-300 text-sm w-full"
|
||||
class="w-full rounded border-gray-300 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Passwort"
|
||||
placeholder={m.admin_col_password()}
|
||||
required
|
||||
class="rounded border-gray-300 text-sm w-full"
|
||||
class="w-full rounded border-gray-300 text-sm"
|
||||
/>
|
||||
|
||||
<!-- Multi-Select for Groups -->
|
||||
<div class="md:col-span-3">
|
||||
<select
|
||||
name="groupIds"
|
||||
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
|
||||
title="Strg+Klick für mehrere"
|
||||
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>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-[10px] text-gray-400 mt-1">Strg+Klick für Mehrfachauswahl</p>
|
||||
<p class="mt-1 text-[10px] text-gray-400">{m.admin_multiselect_hint_full()}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
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"
|
||||
>Anlegen</button
|
||||
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
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === 'tags'}
|
||||
<!-- TAGS SECTION (unchanged logic, just ensuring style consistency) -->
|
||||
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
|
||||
<div class="p-6 border-b border-gray-100 bg-yellow-50/50">
|
||||
<h2 class="text-lg font-bold text-gray-700">Schlagworte</h2>
|
||||
<p class="text-xs text-yellow-800 mt-1">
|
||||
Warnung: Umbenennen oder Löschen wirkt sich auf alle verknüpften Dokumente aus.
|
||||
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
|
||||
<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>
|
||||
<p class="mt-1 text-xs text-yellow-800">
|
||||
{m.admin_tags_warning()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-gray-100 max-h-[600px] overflow-y-auto">
|
||||
{#each data.tags as tag}
|
||||
<li class="px-6 py-3 flex items-center justify-between hover:bg-gray-50 group">
|
||||
<ul class="max-h-[600px] divide-y divide-gray-100 overflow-y-auto">
|
||||
{#each data.tags as tag (tag.id)}
|
||||
<li class="group flex items-center justify-between px-6 py-3 hover:bg-gray-50">
|
||||
{#if editingTagId === tag.id}
|
||||
<form
|
||||
method="POST"
|
||||
@@ -311,17 +304,17 @@
|
||||
await update();
|
||||
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="text"
|
||||
name="name"
|
||||
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="Speichern" class="text-green-600 hover:text-green-800"
|
||||
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
<button aria-label={m.btn_save()} class="text-green-600 hover:text-green-800"
|
||||
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -332,10 +325,10 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
on:click={cancelEditTag}
|
||||
aria-label="Abbrechen"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
onclick={cancelEditTag}
|
||||
aria-label={m.btn_cancel()}
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -346,18 +339,18 @@
|
||||
>
|
||||
</form>
|
||||
{: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}
|
||||
</span>
|
||||
<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
|
||||
on:click={() => startEditTag(tag)}
|
||||
aria-label="Schlagwort bearbeiten"
|
||||
onclick={() => startEditTag(tag)}
|
||||
aria-label={m.admin_btn_edit_tag_label()}
|
||||
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
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -370,16 +363,11 @@
|
||||
method="POST"
|
||||
action="?/deleteTag"
|
||||
use:enhance={({ cancel }) => {
|
||||
// This runs BEFORE the request is sent
|
||||
if (
|
||||
!confirm(
|
||||
'Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.'
|
||||
)
|
||||
!confirm(m.admin_tag_delete_confirm())
|
||||
) {
|
||||
cancel(); // Stop the request
|
||||
cancel();
|
||||
}
|
||||
|
||||
// This runs AFTER the server responds
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
@@ -387,8 +375,11 @@
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="id" value={tag.id} />
|
||||
<button aria-label="Schlagwort löschen" class="p-1 text-gray-400 hover:text-red-600">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
<button
|
||||
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
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -405,28 +396,28 @@
|
||||
</ul>
|
||||
</div>
|
||||
{:else if activeTab === 'groups'}
|
||||
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
|
||||
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
|
||||
<h2 class="text-lg font-bold text-gray-700">Gruppenverwaltung</h2>
|
||||
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
|
||||
>Name</th
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_name()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
|
||||
>Berechtigungen</th
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_permissions()}</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider"
|
||||
>Aktionen</th
|
||||
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_actions()}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each data.groups as group}
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each data.groups as group (group.id)}
|
||||
<tr class="group/row hover:bg-gray-50">
|
||||
{#if editingGroupId === group.id}
|
||||
<!-- EDIT MODE -->
|
||||
@@ -439,24 +430,22 @@
|
||||
await update();
|
||||
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} />
|
||||
|
||||
<!-- Name Input -->
|
||||
<div class="w-full sm:w-1/3">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={group.name}
|
||||
class="w-full text-sm border-brand-mint rounded"
|
||||
class="w-full rounded border-brand-mint text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Permissions Checkboxes -->
|
||||
<div class="flex-1 flex flex-wrap gap-4 items-center h-full pt-2">
|
||||
{#each availablePermissions as perm}
|
||||
<div class="flex h-full flex-1 flex-wrap items-center gap-4 pt-2">
|
||||
{#each availablePermissions as perm (perm)}
|
||||
<label
|
||||
class="inline-flex items-center text-xs font-bold text-gray-600 uppercase"
|
||||
>
|
||||
@@ -465,17 +454,20 @@
|
||||
name="permissions"
|
||||
value={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('_', ' ')}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 self-start sm:self-center">
|
||||
<button type="submit" aria-label="Speichern" class="text-green-600 hover:text-green-800 p-1">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
<button
|
||||
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
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -486,11 +478,11 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={cancelEditGroup}
|
||||
aria-label="Abbrechen"
|
||||
class="text-gray-400 hover:text-red-500 p-1"
|
||||
onclick={cancelEditGroup}
|
||||
aria-label={m.btn_cancel()}
|
||||
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
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -504,40 +496,39 @@
|
||||
</td>
|
||||
{:else}
|
||||
<!-- 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}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each group.permissions as perm}
|
||||
{#each group.permissions as perm (perm)}
|
||||
<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'
|
||||
? 'bg-red-50 text-red-700 border-red-100'
|
||||
: 'bg-gray-100 text-gray-600 border-gray-200'}"
|
||||
? 'border-red-100 bg-red-50 text-red-700'
|
||||
: 'border-gray-200 bg-gray-100 text-gray-600'}"
|
||||
>
|
||||
{perm}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</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">
|
||||
<button
|
||||
on:click={() => startEditGroup(group.id)}
|
||||
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
|
||||
onclick={() => startEditGroup(group.id)}
|
||||
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
|
||||
>
|
||||
Bearbeiten
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteGroup"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm('Gruppe wirklich löschen?')) {
|
||||
if (!confirm(m.admin_group_delete_confirm())) {
|
||||
cancel();
|
||||
}
|
||||
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
@@ -545,10 +536,10 @@
|
||||
>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<button
|
||||
class="text-gray-300 hover:text-red-600 p-1 transition-colors"
|
||||
title="Löschen"
|
||||
class="p-1 text-gray-300 transition-colors hover:text-red-600"
|
||||
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
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -567,34 +558,34 @@
|
||||
</table>
|
||||
|
||||
<!-- CREATE GROUP FORM -->
|
||||
<div class="p-6 bg-gray-50 border-t border-gray-200">
|
||||
<h3 class="text-xs font-bold uppercase text-gray-500 mb-4 tracking-wide">
|
||||
Neue Gruppe anlegen
|
||||
<div class="border-t border-gray-200 bg-gray-50 p-6">
|
||||
<h3 class="mb-4 text-xs font-bold tracking-wide text-gray-500 uppercase">
|
||||
{m.admin_section_new_group()}
|
||||
</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/createGroup"
|
||||
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
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Gruppenname (z.B. Editoren)"
|
||||
placeholder={m.admin_group_name_placeholder()}
|
||||
required
|
||||
class="rounded border-gray-300 text-sm w-full"
|
||||
class="w-full rounded border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 items-center">
|
||||
{#each availablePermissions as perm}
|
||||
<div class="flex items-center gap-4">
|
||||
{#each availablePermissions as perm (perm)}
|
||||
<label class="inline-flex items-center text-xs font-bold text-gray-600 uppercase">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions"
|
||||
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('_', ' ')}
|
||||
</label>
|
||||
@@ -603,9 +594,9 @@
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Anlegen
|
||||
{m.btn_create()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
19
frontend/src/routes/api/documents/[id]/file/+server.ts
Normal file
19
frontend/src/routes/api/documents/[id]/file/+server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from 'process';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, fetch }) => {
|
||||
const backendUrl = `${env.API_INTERNAL_URL || 'http://localhost:8080'}/api/documents/${params.id}/file`;
|
||||
|
||||
const response = await fetch(backendUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response(null, { status: response.status });
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('Content-Type') ?? 'application/octet-stream',
|
||||
'Content-Disposition': response.headers.get('Content-Disposition') ?? ''
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,34 +1,34 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from 'process';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, fetch }) => {
|
||||
// 1. Suchparameter aus der URL des Browsers holen
|
||||
const q = url.searchParams.get('q') || '';
|
||||
// 1. Suchparameter aus der URL des Browsers holen
|
||||
const q = url.searchParams.get('q') || '';
|
||||
|
||||
try {
|
||||
// 3. Anfrage an das Java-Backend weiterleiten (Server-to-Server)
|
||||
// Wir nutzen hier den internen Docker-Hostnamen oder localhost, je nach Netzwerk
|
||||
const backendUrl = `http://localhost:8080/api/persons?q=${encodeURIComponent(q)}`;
|
||||
try {
|
||||
// 3. Anfrage an das Java-Backend weiterleiten (Server-to-Server)
|
||||
// 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 response = await fetch(backendUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const response = await fetch(backendUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Backend Error: ${response.status}`);
|
||||
return json([], { status: response.status });
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.error(`Backend Error: ${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
|
||||
return json(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Proxy Error:", error);
|
||||
return json([], { status: 500 });
|
||||
}
|
||||
// 4. Daten zurück an den Browser schicken
|
||||
return json(data);
|
||||
} catch (error) {
|
||||
console.error('Proxy Error:', error);
|
||||
return json([], { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from 'process';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, fetch }) => {
|
||||
// 1. Suchparameter aus der URL des Browsers holen
|
||||
const q = url.searchParams.get('q') || '';
|
||||
// 1. Suchparameter aus der URL des Browsers holen
|
||||
const q = url.searchParams.get('q') || '';
|
||||
|
||||
try {
|
||||
// 3. Anfrage an das Java-Backend weiterleiten (Server-to-Server)
|
||||
// Wir nutzen hier den internen Docker-Hostnamen oder localhost, je nach Netzwerk
|
||||
const backendUrl = `http://localhost:8080/api/tags?q=${encodeURIComponent(q)}`;
|
||||
try {
|
||||
// 3. Anfrage an das Java-Backend weiterleiten (Server-to-Server)
|
||||
// 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 response = await fetch(backendUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const response = await fetch(backendUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Backend Error: ${response.status}`);
|
||||
return json([], { status: response.status });
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.error(`Backend Error: ${response.status}`);
|
||||
return json([], { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Tags Data", data)
|
||||
const data = await response.json();
|
||||
console.log('Tags Data', data);
|
||||
|
||||
// 4. Daten zurück an den Browser schicken
|
||||
return json(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Proxy Error:", error);
|
||||
return json([], { status: 500 });
|
||||
}
|
||||
// 4. Daten zurück an den Browser schicken
|
||||
return json(data);
|
||||
} catch (error) {
|
||||
console.error('Proxy Error:', error);
|
||||
return json([], { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,61 +2,63 @@ import type { components } from '$lib/generated/api';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
const senderId = url.searchParams.get('senderId') || '';
|
||||
const receiverId = url.searchParams.get('receiverId') || '';
|
||||
const from = url.searchParams.get('from') || '';
|
||||
const to = url.searchParams.get('to') || '';
|
||||
const dir = url.searchParams.get('dir') || 'DESC';
|
||||
const senderId = url.searchParams.get('senderId') || '';
|
||||
const receiverId = url.searchParams.get('receiverId') || '';
|
||||
const from = url.searchParams.get('from') || '';
|
||||
const to = url.searchParams.get('to') || '';
|
||||
const dir = url.searchParams.get('dir') || 'DESC';
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
let documents: components['schemas']['Document'][] = [];
|
||||
let senderName = '';
|
||||
let receiverName = '';
|
||||
let documents: components['schemas']['Document'][] = [];
|
||||
let senderName = '';
|
||||
let receiverName = '';
|
||||
|
||||
const requests: Promise<void>[] = [];
|
||||
const requests: Promise<void>[] = [];
|
||||
|
||||
if (senderId && receiverId) {
|
||||
requests.push(
|
||||
api.GET('/api/documents/conversation', {
|
||||
params: {
|
||||
query: {
|
||||
senderId,
|
||||
receiverId,
|
||||
dir,
|
||||
from: from || undefined,
|
||||
to: to || undefined
|
||||
}
|
||||
}
|
||||
}).then(({ data }) => { documents = data ?? []; })
|
||||
);
|
||||
}
|
||||
if (senderId && receiverId) {
|
||||
requests.push(
|
||||
api
|
||||
.GET('/api/documents/conversation', {
|
||||
params: {
|
||||
query: {
|
||||
senderId,
|
||||
receiverId,
|
||||
dir,
|
||||
from: from || undefined,
|
||||
to: to || undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(({ data }) => {
|
||||
documents = data ?? [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (senderId) {
|
||||
requests.push(
|
||||
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } })
|
||||
.then(({ data }) => {
|
||||
const p = data as { firstName: string; lastName: string } | undefined;
|
||||
if (p) senderName = `${p.firstName} ${p.lastName}`;
|
||||
})
|
||||
);
|
||||
}
|
||||
if (senderId) {
|
||||
requests.push(
|
||||
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
|
||||
const p = data as { firstName: string; lastName: string } | undefined;
|
||||
if (p) senderName = `${p.firstName} ${p.lastName}`;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (receiverId) {
|
||||
requests.push(
|
||||
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } })
|
||||
.then(({ data }) => {
|
||||
const p = data as { firstName: string; lastName: string } | undefined;
|
||||
if (p) receiverName = `${p.firstName} ${p.lastName}`;
|
||||
})
|
||||
);
|
||||
}
|
||||
if (receiverId) {
|
||||
requests.push(
|
||||
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
|
||||
const p = data as { firstName: string; lastName: string } | undefined;
|
||||
if (p) receiverName = `${p.firstName} ${p.lastName}`;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(requests);
|
||||
await Promise.all(requests);
|
||||
|
||||
return {
|
||||
documents,
|
||||
initialValues: { senderName, receiverName },
|
||||
filters: { senderId, receiverId, from, to, dir }
|
||||
};
|
||||
return {
|
||||
documents,
|
||||
initialValues: { senderName, receiverName },
|
||||
filters: { senderId, receiverId, from, to, dir }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,113 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
|
||||
export let data;
|
||||
let { data } = $props();
|
||||
|
||||
// Data & State
|
||||
let documents: typeof data.documents = [];
|
||||
let initialValues = { senderName: '', receiverName: '' };
|
||||
let senderId = $state(untrack(() => data.filters.senderId));
|
||||
let receiverId = $state(untrack(() => data.filters.receiverId));
|
||||
let fromDate = $state(untrack(() => data.filters.from));
|
||||
let toDate = $state(untrack(() => data.filters.to));
|
||||
let sortDir = $state(untrack(() => data.filters.dir));
|
||||
|
||||
// Filter State
|
||||
let senderId = '';
|
||||
let receiverId = '';
|
||||
let fromDate = '';
|
||||
let toDate = '';
|
||||
let sortDir = 'DESC';
|
||||
const documentYears = $derived(
|
||||
data.documents
|
||||
.map((doc) => (doc.documentDate ? new Date(doc.documentDate).getFullYear() : null))
|
||||
.filter((y): y is number => y !== null)
|
||||
);
|
||||
const yearFrom = $derived(documentYears.length > 0 ? Math.min(...documentYears) : null);
|
||||
const yearTo = $derived(documentYears.length > 0 ? Math.max(...documentYears) : null);
|
||||
|
||||
// Reactive Update
|
||||
$: {
|
||||
documents = data.documents;
|
||||
initialValues = data.initialValues;
|
||||
senderId = data.filters.senderId;
|
||||
receiverId = data.filters.receiverId;
|
||||
fromDate = data.filters.from;
|
||||
toDate = data.filters.to;
|
||||
sortDir = data.filters.dir;
|
||||
}
|
||||
// Sync with server data after navigation
|
||||
$effect(() => {
|
||||
senderId = data.filters.senderId;
|
||||
receiverId = data.filters.receiverId;
|
||||
fromDate = data.filters.from;
|
||||
toDate = data.filters.to;
|
||||
sortDir = data.filters.dir;
|
||||
});
|
||||
|
||||
// Filter Logic
|
||||
function applyFilters() {
|
||||
setTimeout(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (senderId) params.set('senderId', senderId);
|
||||
if (receiverId) params.set('receiverId', receiverId);
|
||||
if (fromDate) params.set('from', fromDate);
|
||||
if (toDate) params.set('to', toDate);
|
||||
params.set('dir', sortDir);
|
||||
goto(`?${params.toString()}`, { keepFocus: true });
|
||||
}, 0);
|
||||
}
|
||||
function applyFilters() {
|
||||
const params = new SvelteURLSearchParams();
|
||||
if (senderId) params.set('senderId', senderId);
|
||||
if (receiverId) params.set('receiverId', receiverId);
|
||||
if (fromDate) params.set('from', fromDate);
|
||||
if (toDate) params.set('to', toDate);
|
||||
params.set('dir', sortDir);
|
||||
goto(`/conversations?${params.toString()}`, { keepFocus: true });
|
||||
}
|
||||
|
||||
function toggleSort() {
|
||||
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
|
||||
applyFilters();
|
||||
}
|
||||
function toggleSort() {
|
||||
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function handleSenderChange(event: CustomEvent) {
|
||||
senderId = event.detail.value;
|
||||
applyFilters();
|
||||
}
|
||||
function swapPersons() {
|
||||
const tmp = senderId;
|
||||
senderId = receiverId;
|
||||
receiverId = tmp;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function handleReceiverChange(event: CustomEvent) {
|
||||
receiverId = event.detail.value;
|
||||
applyFilters();
|
||||
}
|
||||
const enrichedDocuments = $derived(
|
||||
data.documents.map((doc, i) => {
|
||||
const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null;
|
||||
const prevYear =
|
||||
i > 0 && data.documents[i - 1].documentDate
|
||||
? new Date(data.documents[i - 1].documentDate!).getFullYear()
|
||||
: null;
|
||||
return { doc, year, showYearDivider: year !== null && year !== prevYear };
|
||||
})
|
||||
);
|
||||
</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 -->
|
||||
<div class="mb-8 border-b border-brand-navy/10 pb-4">
|
||||
<h1 class="text-3xl font-serif font-medium text-brand-navy">Konversationen</h1>
|
||||
<p class="text-brand-navy/60 font-sans text-sm mt-2">
|
||||
Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.
|
||||
<h1 class="font-serif text-3xl font-medium text-brand-navy">{m.conv_heading()}</h1>
|
||||
<p class="mt-2 font-sans text-sm text-brand-navy/60">
|
||||
{m.conv_subtitle()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- FILTER BAR -->
|
||||
<div class="bg-white p-8 shadow-sm border border-brand-sand mb-10 relative z-20">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-6">
|
||||
<div class="relative z-20 mb-10 border border-brand-sand bg-white p-8 shadow-sm">
|
||||
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
|
||||
<!-- Sender -->
|
||||
<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
|
||||
name="senderId"
|
||||
label="Person A (Absender)"
|
||||
value={senderId}
|
||||
initialName={initialValues.senderName}
|
||||
on:change={handleSenderChange}
|
||||
label={m.conv_label_person_a()}
|
||||
bind:value={senderId}
|
||||
initialName={data.initialValues.senderName}
|
||||
restrictToCorrespondentsOf={receiverId || undefined}
|
||||
onchange={() => applyFilters()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Swap button — always rendered to hold grid column width on desktop.
|
||||
On mobile: hidden (display:none) when no persons selected so no gap appears.
|
||||
On desktop: invisible (visibility:hidden) when no persons so both 1fr columns stay equal. -->
|
||||
<div class="{senderId && receiverId ? 'flex' : 'hidden md:flex'} items-end">
|
||||
<button
|
||||
data-testid="conv-swap-btn"
|
||||
onclick={swapPersons}
|
||||
class="flex w-full items-center justify-center gap-2 border border-brand-sand px-3 py-2.5 text-xs font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white md:w-auto {senderId &&
|
||||
receiverId
|
||||
? ''
|
||||
: 'invisible'}"
|
||||
title={m.conv_swap_btn()}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 flex-shrink-0 md:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="md:hidden">{m.conv_swap_btn()}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Receiver -->
|
||||
<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
|
||||
name="receiverId"
|
||||
label="Person B (Empfänger)"
|
||||
value={receiverId}
|
||||
initialName={initialValues.receiverName}
|
||||
on:change={handleReceiverChange}
|
||||
label={m.conv_label_person_b()}
|
||||
bind:value={receiverId}
|
||||
initialName={data.initialValues.receiverName}
|
||||
restrictToCorrespondentsOf={senderId || undefined}
|
||||
onchange={() => applyFilters()}
|
||||
/>
|
||||
</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 -->
|
||||
<div>
|
||||
<label
|
||||
for="dateFrom"
|
||||
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
|
||||
>Zeitraum von</label
|
||||
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||
>{m.conv_label_from()}</label
|
||||
>
|
||||
<input
|
||||
id="dateFrom"
|
||||
type="date"
|
||||
bind:value={fromDate}
|
||||
on:change={() => applyFilters()}
|
||||
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
|
||||
onchange={() => applyFilters()}
|
||||
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -115,28 +157,28 @@
|
||||
<div>
|
||||
<label
|
||||
for="dateTo"
|
||||
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
|
||||
>Zeitraum bis</label
|
||||
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||
>{m.conv_label_to()}</label
|
||||
>
|
||||
<input
|
||||
id="dateTo"
|
||||
type="date"
|
||||
bind:value={toDate}
|
||||
on:change={() => applyFilters()}
|
||||
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
|
||||
onchange={() => applyFilters()}
|
||||
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sort Toggle -->
|
||||
<div>
|
||||
<button
|
||||
on:click={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"
|
||||
onclick={toggleSort}
|
||||
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">Sortierung:</span>
|
||||
<span>{sortDir === 'DESC' ? 'Neueste zuerst' : 'Älteste zuerst'}</span>
|
||||
<span class="mr-2">{m.conv_sort_label()}</span>
|
||||
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
|
||||
<svg
|
||||
class="w-4 h-4 ml-2 transform {sortDir === 'ASC'
|
||||
class="ml-2 h-4 w-4 transform {sortDir === 'ASC'
|
||||
? 'rotate-180'
|
||||
: ''} transition-transform"
|
||||
fill="none"
|
||||
@@ -154,10 +196,10 @@
|
||||
<!-- RESULTS LIST SECTION -->
|
||||
{#if !senderId || !receiverId}
|
||||
<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">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
<div class="mb-4 rounded-full bg-brand-sand/30 p-4 text-brand-navy">
|
||||
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -166,47 +208,80 @@
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
<p class="text-brand-navy font-serif text-lg">Wählen Sie zwei Personen aus</p>
|
||||
<p class="text-gray-500 font-sans text-sm mt-1">Die Korrespondenz wird hier angezeigt.</p>
|
||||
<p class="font-serif text-lg text-brand-navy">{m.conv_empty_heading()}</p>
|
||||
<p class="mt-1 font-sans text-sm text-gray-500">{m.conv_empty_text()}</p>
|
||||
</div>
|
||||
{:else if documents.length === 0}
|
||||
{:else if data.documents.length === 0}
|
||||
<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">Keine Dokumente gefunden.</p>
|
||||
<p class="text-gray-400 text-sm mt-2">Versuchen Sie, den Zeitraum anzupassen.</p>
|
||||
<p class="font-serif text-brand-navy">{m.conv_no_results_heading()}</p>
|
||||
<p class="mt-2 text-sm text-gray-400">{m.conv_no_results_text()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Summary bar -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
{#if yearFrom !== null && yearTo !== null}
|
||||
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-brand-navy/70">
|
||||
{m.conv_summary({ count: data.documents.length, yearFrom, yearTo })}
|
||||
</p>
|
||||
{:else}
|
||||
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-brand-navy/70">
|
||||
{data.documents.length}
|
||||
</p>
|
||||
{/if}
|
||||
{#if data.canWrite}
|
||||
<a
|
||||
data-testid="conv-new-doc-link"
|
||||
href="/documents/new?senderId={senderId}&receiverId={receiverId}"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"
|
||||
></path>
|
||||
</svg>
|
||||
{m.conv_new_doc_link()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- CHAT CONTAINER -->
|
||||
<!-- Added: White background, Border, Shadow to separate from page -->
|
||||
<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 -->
|
||||
<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>
|
||||
|
||||
<!-- Scrollable Area (optional, if you want max-height) -->
|
||||
<div class="p-6 md:p-8">
|
||||
<!-- TIGHTER GAP: Changed from gap-8 to gap-4 -->
|
||||
<div class="flex flex-col gap-4 relative z-10">
|
||||
{#each documents as doc}
|
||||
<div class="relative z-10 flex flex-col gap-4">
|
||||
{#each enrichedDocuments as { doc, year, showYearDivider } (doc.id)}
|
||||
{#if showYearDivider}
|
||||
<div data-testid="year-divider" class="relative flex items-center py-2 text-center">
|
||||
<div class="flex-grow border-t border-brand-sand"></div>
|
||||
<span
|
||||
class="mx-4 font-sans text-xs font-bold tracking-widest text-brand-navy/40 uppercase"
|
||||
>{year}</span
|
||||
>
|
||||
<div class="flex-grow border-t border-brand-sand"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{@const isRight = doc.sender?.id === senderId}
|
||||
|
||||
<!-- Message Row -->
|
||||
<div class="flex w-full {isRight ? 'justify-end' : 'justify-start'}">
|
||||
<!-- Bubble Group -->
|
||||
<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'}"
|
||||
>
|
||||
<!-- AVATAR (Small) -->
|
||||
<div class="flex-shrink-0 mt-auto mb-1 hidden sm:block">
|
||||
<!-- AVATAR -->
|
||||
<div class="mt-auto mb-1 hidden flex-shrink-0 sm:block">
|
||||
<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
|
||||
? 'bg-brand-navy text-white border-brand-navy'
|
||||
: 'bg-white text-brand-navy border-brand-sand'}"
|
||||
? 'border-brand-navy bg-brand-navy text-white'
|
||||
: 'border-brand-sand bg-white text-brand-navy'}"
|
||||
>
|
||||
{#if doc.sender}
|
||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||
@@ -217,18 +292,17 @@
|
||||
</div>
|
||||
|
||||
<!-- BUBBLE CARD -->
|
||||
<!-- Adjusted padding (p-4) and added light bg to left bubbles for contrast -->
|
||||
<a
|
||||
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
|
||||
? 'bg-brand-navy text-white border-brand-navy rounded-br-none'
|
||||
: 'bg-brand-sand/10 text-brand-navy border-brand-sand rounded-bl-none'}"
|
||||
? 'rounded-br-none border-brand-navy bg-brand-navy text-white'
|
||||
: 'rounded-bl-none border-brand-sand bg-brand-sand/10 text-brand-navy'}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start gap-4 mb-2">
|
||||
<div class="mb-2 flex items-start justify-between gap-4">
|
||||
<h3
|
||||
class="font-serif font-medium text-sm leading-snug {isRight
|
||||
class="font-serif text-sm leading-snug font-medium {isRight
|
||||
? 'text-white'
|
||||
: 'text-brand-navy'}"
|
||||
>
|
||||
@@ -237,7 +311,7 @@
|
||||
|
||||
<!-- Status Dot -->
|
||||
<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'
|
||||
? 'bg-brand-mint'
|
||||
: 'bg-yellow-400'}"
|
||||
@@ -248,12 +322,12 @@
|
||||
|
||||
<!-- Metadata -->
|
||||
<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-gray-500'}"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
{doc.documentDate ? new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(doc.documentDate + 'T12:00:00')) : '—'}
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
</span>
|
||||
{#if doc.location}
|
||||
<span class="flex items-center">
|
||||
|
||||
163
frontend/src/routes/conversations/page.svelte.spec.ts
Normal file
163
frontend/src/routes/conversations/page.svelte.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── Test data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
documents: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
|
||||
};
|
||||
|
||||
const withPersons = {
|
||||
...baseData,
|
||||
filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
|
||||
};
|
||||
|
||||
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'd1',
|
||||
title: 'Testbrief',
|
||||
originalFilename: 'testbrief.pdf',
|
||||
status: 'UPLOADED' as const,
|
||||
documentDate: '1923-04-12',
|
||||
location: 'Berlin',
|
||||
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
|
||||
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
|
||||
tags: [],
|
||||
transcription: undefined,
|
||||
filePath: undefined,
|
||||
createdAt: '1923-04-12T00:00:00Z',
|
||||
updatedAt: '1923-04-12T00:00:00Z',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const withDocs = {
|
||||
...withPersons,
|
||||
documents: [makeDoc()]
|
||||
};
|
||||
|
||||
// ─── Empty state ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – empty state', () => {
|
||||
it('shows the "select two persons" prompt when no persons are selected', async () => {
|
||||
render(Page, { data: baseData });
|
||||
await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the swap button when no persons are selected', async () => {
|
||||
render(Page, { data: baseData });
|
||||
// Button is always in the DOM (holds grid column width on desktop) but made invisible
|
||||
await expect.element(page.getByTestId('conv-swap-btn')).toHaveClass('invisible');
|
||||
});
|
||||
|
||||
it('does not show the new document link when no persons are selected', async () => {
|
||||
render(Page, { data: baseData });
|
||||
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── No results ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – no results', () => {
|
||||
it('shows "no documents found" when both persons are selected but there are no documents', async () => {
|
||||
render(Page, { data: withPersons });
|
||||
await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Swap button ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – swap button', () => {
|
||||
it('shows the swap button when both persons are selected', async () => {
|
||||
render(Page, { data: withPersons });
|
||||
await expect.element(page.getByTestId('conv-swap-btn')).not.toHaveClass('invisible');
|
||||
});
|
||||
|
||||
it('calls goto with swapped sender and receiver when clicked', async () => {
|
||||
const { goto } = await import('$app/navigation');
|
||||
vi.mocked(goto).mockClear();
|
||||
render(Page, { data: withPersons });
|
||||
document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]')!.click();
|
||||
expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything());
|
||||
expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – summary', () => {
|
||||
it('shows document count and year range when documents are loaded', async () => {
|
||||
const data = {
|
||||
...withPersons,
|
||||
documents: [
|
||||
makeDoc({ documentDate: '1923-04-12' }),
|
||||
makeDoc({ id: 'd2', documentDate: '1965-08-03' })
|
||||
]
|
||||
};
|
||||
render(Page, { data });
|
||||
const summary = page.getByTestId('conv-summary');
|
||||
await expect.element(summary).toHaveTextContent('2');
|
||||
await expect.element(summary).toHaveTextContent('1923');
|
||||
await expect.element(summary).toHaveTextContent('1965');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Year dividers ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – year dividers', () => {
|
||||
it('renders a year divider for the first document', async () => {
|
||||
render(Page, { data: withDocs });
|
||||
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
||||
});
|
||||
|
||||
it('renders a divider for each new year in the document list', async () => {
|
||||
const data = {
|
||||
...withPersons,
|
||||
documents: [
|
||||
makeDoc({ documentDate: '1923-04-12' }),
|
||||
makeDoc({ id: 'd2', documentDate: '1965-08-03' })
|
||||
]
|
||||
};
|
||||
render(Page, { data });
|
||||
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
||||
await expect.element(page.getByTestId('year-divider').nth(1)).toHaveTextContent('1965');
|
||||
});
|
||||
|
||||
it('does not render a second divider for documents from the same year', async () => {
|
||||
const data = {
|
||||
...withPersons,
|
||||
documents: [
|
||||
makeDoc({ documentDate: '1923-04-12' }),
|
||||
makeDoc({ id: 'd2', documentDate: '1923-09-01' })
|
||||
]
|
||||
};
|
||||
render(Page, { data });
|
||||
// Only one divider for 1923; 1965 divider should not appear
|
||||
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
||||
await expect.element(page.getByTestId('year-divider').nth(1)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── New document link ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – new document link', () => {
|
||||
it('shows the link with correct href for a write user', async () => {
|
||||
render(Page, { data: { ...withDocs, canWrite: true } });
|
||||
const link = page.getByTestId('conv-new-doc-link');
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/new?senderId=p1&receiverId=p2');
|
||||
});
|
||||
|
||||
it('hides the link for a read-only user', async () => {
|
||||
render(Page, { data: { ...withDocs, canWrite: false } });
|
||||
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { resolve } from '$app/paths';
|
||||
</script>
|
||||
|
||||
<a href={resolve('/demo/paraglide')}>paraglide</a>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user