Compare commits
52 Commits
cccc12429c
...
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 | ||
|
|
b07391541b |
@@ -28,6 +28,10 @@ jobs:
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run unit and component tests
|
||||
run: npm test
|
||||
working-directory: frontend
|
||||
|
||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
||||
cd frontend && npm run lint
|
||||
@@ -8,7 +8,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Collaboration
|
||||
|
||||
See [COLLABORATING.md](./COLLABORATING.md) for the full rules: issue tracking workflow, commit message conventions, the Research → Plan → Implement → Validate cycle, and code style expectations.
|
||||
See [COLLABORATING.md](./COLLABORATING.md) for the full rules: issue tracking workflow, commit message conventions, and the Research → Plan → Implement → Validate cycle.
|
||||
|
||||
See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS trade-offs (KISS wins), and SOLID principles applied to this stack.
|
||||
|
||||
---
|
||||
|
||||
|
||||
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`.
|
||||
@@ -122,9 +122,30 @@ Closes #7
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
## Code Style Reminders
|
||||
### 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 — solve the problem in front of you
|
||||
- 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)
|
||||
|
||||
@@ -4,6 +4,7 @@ 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.service.DocumentService;
|
||||
@@ -33,11 +34,23 @@ public class PersonController {
|
||||
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 documentService.getDocumentsBySender(id);
|
||||
}
|
||||
|
||||
@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");
|
||||
@@ -49,13 +62,14 @@ public class PersonController {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody Map<String, String> body) {
|
||||
String firstName = body.get("firstName");
|
||||
String lastName = body.get("lastName");
|
||||
if (firstName == null || firstName.isBlank() || lastName == null || lastName.isBlank()) {
|
||||
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");
|
||||
}
|
||||
return ResponseEntity.ok(personService.updatePerson(id, firstName.trim(), lastName.trim(), body.get("alias")));
|
||||
dto.setFirstName(dto.getFirstName().trim());
|
||||
dto.setLastName(dto.getLastName().trim());
|
||||
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/merge")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,8 +29,8 @@ 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;
|
||||
@@ -102,8 +102,10 @@ 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) {
|
||||
@@ -180,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<>();
|
||||
|
||||
@@ -217,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
|
||||
@@ -254,6 +256,10 @@ public class DocumentService {
|
||||
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();
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.service;
|
||||
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;
|
||||
@@ -30,6 +31,13 @@ public class PersonService {
|
||||
.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);
|
||||
}
|
||||
@@ -58,12 +66,18 @@ public class PersonService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person updatePerson(UUID id, String firstName, String lastName, String alias) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ 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;
|
||||
@@ -30,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() {
|
||||
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -121,4 +121,15 @@ class DocumentServiceTest {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ 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;
|
||||
|
||||
@@ -83,6 +85,115 @@ class PersonServiceTest {
|
||||
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
|
||||
|
||||
@@ -5,7 +5,9 @@ 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;
|
||||
@@ -20,6 +22,7 @@ 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 {
|
||||
@@ -109,6 +112,110 @@ class UserServiceTest {
|
||||
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
|
||||
|
||||
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",
|
||||
|
||||
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": []
|
||||
}
|
||||
@@ -9,11 +9,11 @@ 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: admin)
|
||||
* E2E_PASSWORD (default: admin123)
|
||||
*/
|
||||
setup('authenticate', async ({ page }) => {
|
||||
const username = process.env.E2E_USERNAME ?? 'admin';
|
||||
const password = process.env.E2E_PASSWORD ?? 'admin';
|
||||
const password = process.env.E2E_PASSWORD ?? 'admin123';
|
||||
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Benutzername').fill(username);
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test';
|
||||
export async function login(
|
||||
page: Page,
|
||||
username = process.env.E2E_USERNAME ?? 'admin',
|
||||
password = process.env.E2E_PASSWORD ?? 'admin'
|
||||
password = process.env.E2E_PASSWORD ?? 'admin123'
|
||||
) {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Benutzername').fill(username);
|
||||
|
||||
@@ -3,16 +3,24 @@ import { test, expect } from '@playwright/test';
|
||||
test.describe('Language selector', () => {
|
||||
test('shows DE, EN, ES buttons in the header', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('banner').getByRole('button', { name: 'DE', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('banner').getByRole('button', { name: 'EN', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('banner').getByRole('button', { name: 'ES', exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('banner').getByRole('button', { name: 'DE', exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('banner').getByRole('button', { name: 'EN', exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('banner').getByRole('button', { name: 'ES', exact: true })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('switching to EN translates the navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
|
||||
await expect(page.getByRole('navigation').getByRole('link', { name: 'Documents' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('navigation').getByRole('link', { name: 'Documents' })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('navigation').getByRole('link', { name: 'Persons' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -21,15 +29,27 @@ test.describe('Language selector', () => {
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
|
||||
await page.goto('/persons');
|
||||
await expect(page.getByRole('navigation').getByRole('link', { name: 'Documents' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('navigation').getByRole('link', { name: 'Documents' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('switching back to DE restores German', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('navigation').getByRole('link', { name: 'Documents' })
|
||||
).toBeVisible();
|
||||
await page.getByRole('banner').getByRole('button', { name: 'DE', exact: true }).click();
|
||||
await expect(page.getByRole('navigation').getByRole('link', { name: 'Dokumente' })).toBeVisible();
|
||||
// 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 }) => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,29 @@ test.describe('Person detail', () => {
|
||||
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', () => {
|
||||
@@ -73,34 +96,94 @@ test.describe('New person', () => {
|
||||
});
|
||||
|
||||
test.describe('Person detail — sort toggle', () => {
|
||||
test('sort toggle changes the button label when person has documents', async ({ page }) => {
|
||||
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]');
|
||||
|
||||
const sortBtn = page.getByRole('button', { name: /Neueste zuerst|Älteste zuerst/i });
|
||||
if (await sortBtn.isVisible()) {
|
||||
await expect(sortBtn).toContainText('Neueste zuerst');
|
||||
await sortBtn.click();
|
||||
await expect(sortBtn).toContainText('Älteste zuerst');
|
||||
await sortBtn.click();
|
||||
await expect(sortBtn).toContainText('Neueste zuerst');
|
||||
await page.screenshot({ path: 'test-results/e2e/person-sort-toggle.png' });
|
||||
// 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('has a conversations link that pre-fills the person', async ({ page }) => {
|
||||
test('co-correspondent chips link to conversations pre-filled with both persons', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/persons');
|
||||
const firstLink = page.locator('a[href^="/persons/"]').first();
|
||||
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();
|
||||
const convLink = page.getByRole('link', { name: /Konversationen/i });
|
||||
await expect(convLink).toBeVisible();
|
||||
await expect(convLink).toHaveAttribute('href', `/conversations?senderId=${personId}`);
|
||||
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=.+/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,3 +211,84 @@ test.describe('Conversations', () => {
|
||||
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",
|
||||
|
||||
"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.",
|
||||
@@ -11,13 +10,11 @@
|
||||
"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.",
|
||||
|
||||
"nav_documents": "Dokumente",
|
||||
"nav_persons": "Personen",
|
||||
"nav_conversations": "Konversationen",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Abmelden",
|
||||
|
||||
"btn_save": "Speichern",
|
||||
"btn_cancel": "Abbrechen",
|
||||
"btn_edit": "Bearbeiten",
|
||||
@@ -26,7 +23,6 @@
|
||||
"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",
|
||||
@@ -47,12 +43,10 @@
|
||||
"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",
|
||||
@@ -68,7 +62,6 @@
|
||||
"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",
|
||||
@@ -79,7 +72,6 @@
|
||||
"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",
|
||||
@@ -92,17 +84,14 @@
|
||||
"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",
|
||||
@@ -111,9 +100,21 @@
|
||||
"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)",
|
||||
@@ -127,7 +128,9 @@
|
||||
"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",
|
||||
@@ -156,7 +159,6 @@
|
||||
"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",
|
||||
@@ -164,9 +166,7 @@
|
||||
"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...",
|
||||
@@ -175,5 +175,24 @@
|
||||
"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."
|
||||
"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",
|
||||
|
||||
"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.",
|
||||
@@ -11,13 +10,11 @@
|
||||
"error_forbidden": "You do not have permission for this action.",
|
||||
"error_validation_error": "The input is invalid.",
|
||||
"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",
|
||||
@@ -26,7 +23,6 @@
|
||||
"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",
|
||||
@@ -47,12 +43,10 @@
|
||||
"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",
|
||||
@@ -68,7 +62,6 @@
|
||||
"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",
|
||||
@@ -79,7 +72,6 @@
|
||||
"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",
|
||||
@@ -92,17 +84,14 @@
|
||||
"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",
|
||||
@@ -111,9 +100,21 @@
|
||||
"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)",
|
||||
@@ -127,7 +128,9 @@
|
||||
"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",
|
||||
@@ -156,7 +159,6 @@
|
||||
"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}",
|
||||
@@ -164,9 +166,7 @@
|
||||
"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...",
|
||||
@@ -176,5 +176,23 @@
|
||||
"comp_taginput_placeholder_filter": "Filter by tags...",
|
||||
"comp_taginput_remove": "Remove tag",
|
||||
"comp_taginput_create_hint": "Press Enter to create tag.",
|
||||
"person_btn_conversations": "View conversations"
|
||||
"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",
|
||||
|
||||
"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.",
|
||||
@@ -11,13 +10,11 @@
|
||||
"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.",
|
||||
|
||||
"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",
|
||||
@@ -26,7 +23,6 @@
|
||||
"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",
|
||||
@@ -47,12 +43,10 @@
|
||||
"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",
|
||||
@@ -68,7 +62,6 @@
|
||||
"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",
|
||||
@@ -79,7 +72,6 @@
|
||||
"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",
|
||||
@@ -92,17 +84,14 @@
|
||||
"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",
|
||||
@@ -111,9 +100,21 @@
|
||||
"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)",
|
||||
@@ -127,7 +128,9 @@
|
||||
"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",
|
||||
@@ -156,7 +159,6 @@
|
||||
"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}",
|
||||
@@ -164,9 +166,7 @@
|
||||
"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...",
|
||||
@@ -176,5 +176,23 @@
|
||||
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
|
||||
"comp_taginput_remove": "Eliminar etiqueta",
|
||||
"comp_taginput_create_hint": "Pulse Enter para crear etiqueta.",
|
||||
"person_btn_conversations": "Ver conversaciones"
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"prepare": "svelte-kit sync || true && git -C .. config core.hooksPath .husky 2>/dev/null || true",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
|
||||
@@ -8,9 +8,5 @@
|
||||
"pathPattern": "./messages/{locale}.json"
|
||||
},
|
||||
"baseLocale": "de",
|
||||
"locales": [
|
||||
"de",
|
||||
"en",
|
||||
"es"
|
||||
]
|
||||
"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 {
|
||||
|
||||
@@ -11,7 +11,12 @@ const handleLocaleDetection: Handle = ({ event, resolve }) => {
|
||||
if (!event.cookies.get(cookieName)) {
|
||||
const locale = detectLocale(event.request.headers.get('accept-language') ?? '');
|
||||
if (locale) {
|
||||
event.cookies.set(cookieName, locale, { path: '/', sameSite: 'lax', maxAge: cookieMaxAge, httpOnly: false });
|
||||
event.cookies.set(cookieName, locale, {
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
maxAge: cookieMaxAge,
|
||||
httpOnly: false
|
||||
});
|
||||
}
|
||||
}
|
||||
return resolve(event);
|
||||
@@ -25,65 +30,68 @@ const handleAuth: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
|
||||
event.request = request;
|
||||
const handleParaglide: Handle = ({ event, resolve }) =>
|
||||
paraglideMiddleware(event.request, ({ request, locale }) => {
|
||||
event.request = request;
|
||||
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const userGroup: Handle = async ({ event, resolve }) => {
|
||||
const auth = event.cookies.get('auth_token');
|
||||
const auth = event.cookies.get('auth_token');
|
||||
|
||||
if (auth) {
|
||||
try {
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const response = await fetch(`${apiUrl}/api/users/me`, {
|
||||
headers: { Authorization: auth }
|
||||
if (auth) {
|
||||
try {
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const response = await fetch(`${apiUrl}/api/users/me`, {
|
||||
headers: { Authorization: auth }
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
event.locals.user = user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user in hook:', error);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
event.locals.user = user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user in hook:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
|
||||
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
||||
const isNotLoginTest = !request.url.includes('/api/users/me');
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
||||
|
||||
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) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
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, 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,123 +1,143 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
type Person = components['schemas']['Person'];
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
selectedPersons?: Person[];
|
||||
}
|
||||
interface Props {
|
||||
selectedPersons?: Person[];
|
||||
}
|
||||
|
||||
let { selectedPersons = $bindable([]) }: Props = $props();
|
||||
let { selectedPersons = $bindable([]) }: Props = $props();
|
||||
|
||||
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('');
|
||||
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 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 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 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 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 selectPerson(person: Person) {
|
||||
selectedPersons = [...selectedPersons, person];
|
||||
searchTerm = '';
|
||||
showDropdown = false;
|
||||
results = [];
|
||||
}
|
||||
function selectPerson(person: Person) {
|
||||
selectedPersons = [...selectedPersons, person];
|
||||
searchTerm = '';
|
||||
showDropdown = false;
|
||||
results = [];
|
||||
}
|
||||
|
||||
function removePerson(id: string | undefined) {
|
||||
selectedPersons = selectedPersons.filter(p => p.id !== id);
|
||||
}
|
||||
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); } };
|
||||
}
|
||||
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 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"
|
||||
onclick={() => removePerson(person.id)}
|
||||
class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5"
|
||||
aria-label={m.comp_multiselect_remove()}
|
||||
>
|
||||
<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}
|
||||
oninput={handleInput}
|
||||
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||
placeholder={selectedPersons.length === 0 ? m.comp_multiselect_placeholder() : ''}
|
||||
class="flex-1 min-w-[120px] border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
|
||||
/>
|
||||
</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">{m.comp_multiselect_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person}
|
||||
<div
|
||||
class="cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-brand-sand/30 text-gray-900"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && 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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonMultiSelect from './PersonMultiSelect.svelte';
|
||||
|
||||
@@ -29,6 +29,7 @@ function receiverInputs() {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,129 +1,154 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
type Person = components['schemas']['Person'];
|
||||
import { untrack } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
initialName?: string;
|
||||
onchange?: (value: string) => void;
|
||||
}
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
initialName?: string;
|
||||
restrictToCorrespondentsOf?: string;
|
||||
onchange?: (value: string) => void;
|
||||
}
|
||||
|
||||
let { name, label, value = $bindable(''), initialName = '', onchange }: Props = $props();
|
||||
let {
|
||||
name,
|
||||
label,
|
||||
value = $bindable(''),
|
||||
initialName = '',
|
||||
restrictToCorrespondentsOf,
|
||||
onchange
|
||||
}: Props = $props();
|
||||
|
||||
let searchTerm = $state('');
|
||||
let searchTerm = $state(initialName);
|
||||
|
||||
// Sync with external changes (e.g. reset button) — also sets the initial value
|
||||
$effect(() => {
|
||||
searchTerm = initialName;
|
||||
});
|
||||
let results: Person[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
let results: Person[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
function handleInput() {
|
||||
if (value && searchTerm !== initialName) {
|
||||
value = '';
|
||||
onchange?.('');
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
if (value && searchTerm !== initialName) {
|
||||
value = '';
|
||||
onchange?.('');
|
||||
}
|
||||
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)}`);
|
||||
results = res.ok ? await res.json() : [];
|
||||
} 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: Person) {
|
||||
value = person.id!;
|
||||
searchTerm = `${person.firstName} ${person.lastName}`;
|
||||
showDropdown = false;
|
||||
onchange?.(person.id!);
|
||||
}
|
||||
function selectPerson(person: Person) {
|
||||
value = person.id!;
|
||||
searchTerm = `${person.firstName} ${person.lastName}`;
|
||||
showDropdown = false;
|
||||
onchange?.(person.id!);
|
||||
}
|
||||
|
||||
let inputEl: HTMLInputElement;
|
||||
let dropdownStyle = $state('');
|
||||
|
||||
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: 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); }
|
||||
};
|
||||
}
|
||||
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 onscroll={updateDropdownPosition} onresize={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}
|
||||
oninput={handleInput}
|
||||
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||
placeholder={m.comp_typeahead_placeholder()}
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<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">{m.comp_typeahead_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person}
|
||||
<div
|
||||
class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-blue-100 text-gray-900"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && 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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonTypeahead from './PersonTypeahead.svelte';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
@@ -30,6 +30,7 @@ function hiddenInput(name: string) {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -117,9 +118,12 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Mustermann, Max' }))
|
||||
.not.toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
||||
});
|
||||
|
||||
@@ -129,7 +133,8 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')?.value).toBe('1');
|
||||
});
|
||||
@@ -141,7 +146,8 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
@@ -151,7 +157,8 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
});
|
||||
});
|
||||
@@ -167,7 +174,8 @@ describe('PersonTypeahead – clearing a selection', () => {
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
onchange.mockClear();
|
||||
|
||||
@@ -179,6 +187,69 @@ describe('PersonTypeahead – clearing a selection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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', () => {
|
||||
|
||||
@@ -1,150 +1,156 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
tags?: string[];
|
||||
allowCreation?: boolean;
|
||||
}
|
||||
interface Props {
|
||||
tags?: string[];
|
||||
allowCreation?: boolean;
|
||||
}
|
||||
|
||||
let { tags = $bindable([]), allowCreation = true }: Props = $props();
|
||||
let { tags = $bindable([]), allowCreation = true }: Props = $props();
|
||||
|
||||
let inputVal = $state('');
|
||||
let suggestions: string[] = $state([]);
|
||||
let activeIndex = $state(-1);
|
||||
let showSuggestions = $state(false);
|
||||
let inputVal = $state('');
|
||||
let suggestions: string[] = $state([]);
|
||||
let activeIndex = $state(-1);
|
||||
let showSuggestions = $state(false);
|
||||
|
||||
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);
|
||||
suggestions = names.filter((t) => !tags.includes(t));
|
||||
showSuggestions = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Tag fetch error', e);
|
||||
}
|
||||
}
|
||||
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 addTag(tag: string) {
|
||||
const trimmed = tag.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
tags = [...tags, trimmed];
|
||||
}
|
||||
inputVal = '';
|
||||
suggestions = [];
|
||||
showSuggestions = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
function addTag(tag: string) {
|
||||
const trimmed = tag.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
tags = [...tags, trimmed];
|
||||
}
|
||||
inputVal = '';
|
||||
suggestions = [];
|
||||
showSuggestions = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
|
||||
function removeTag(index: number) {
|
||||
tags = tags.filter((_, i) => i !== index);
|
||||
}
|
||||
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 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); } };
|
||||
}
|
||||
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" 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]"
|
||||
>
|
||||
<!-- Render Selected Tags -->
|
||||
{#each tags as tag, i}
|
||||
<span
|
||||
class="bg-brand-sand/30 text-brand-navy text-sm font-medium px-2 py-1 rounded flex items-center gap-1"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
<!-- Tag Container -->
|
||||
<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"
|
||||
>
|
||||
<!-- Render Selected Tags -->
|
||||
{#each tags as tag, i (i)}
|
||||
<span
|
||||
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"
|
||||
onclick={() => removeTag(i)}
|
||||
aria-label={m.comp_taginput_remove()}
|
||||
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
||||
>
|
||||
<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 Field -->
|
||||
<div class="relative flex-1 min-w-[120px]">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={inputVal}
|
||||
oninput={() => fetchSuggestions(inputVal)}
|
||||
onkeydown={handleKeydown}
|
||||
onfocus={() => fetchSuggestions(inputVal)}
|
||||
placeholder={tags.length === 0
|
||||
<!-- Input Field -->
|
||||
<div class="relative min-w-[120px] flex-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={inputVal}
|
||||
oninput={() => fetchSuggestions(inputVal)}
|
||||
onkeydown={handleKeydown}
|
||||
onfocus={() => fetchSuggestions(inputVal)}
|
||||
placeholder={tags.length === 0
|
||||
? allowCreation
|
||||
? m.comp_taginput_placeholder_create()
|
||||
: m.comp_taginput_placeholder_filter()
|
||||
: ''}
|
||||
class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
|
||||
/>
|
||||
class="h-full w-full border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||
/>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
{#each suggestions as suggestion, 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'
|
||||
<!-- Typeahead Dropdown -->
|
||||
{#if showSuggestions && suggestions.length > 0}
|
||||
<ul
|
||||
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 (i)}
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
tabindex="0"
|
||||
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>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if allowCreation}
|
||||
<p class="text-xs text-gray-400 mt-1">{m.comp_taginput_create_hint()}</p>
|
||||
{/if}
|
||||
onclick={() => addTag(suggestion)}
|
||||
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if allowCreation}
|
||||
<p class="mt-1 text-xs text-gray-400">{m.comp_taginput_create_hint()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import TagInput from './TagInput.svelte';
|
||||
|
||||
@@ -24,6 +24,7 @@ function mockFetchEmpty() {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -32,17 +33,13 @@ afterEach(() => {
|
||||
describe('TagInput – rendering', () => {
|
||||
it('shows creation placeholder when allowCreation=true and no tags', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
await expect
|
||||
.element(page.getByPlaceholder('Schlagworte hinzufügen...'))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByPlaceholder('Schlagworte hinzufügen...')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-empty.png' });
|
||||
});
|
||||
|
||||
it('shows filter placeholder when allowCreation=false', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: false });
|
||||
await expect
|
||||
.element(page.getByPlaceholder('Nach Schlagworten filtern...'))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByPlaceholder('Nach Schlagworten filtern...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders existing tags as chips', async () => {
|
||||
@@ -117,8 +114,8 @@ 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"
|
||||
const removeButtons = page.getByRole('button', { name: 'Schlagwort entfernen' });
|
||||
await removeButtons.first().click();
|
||||
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' });
|
||||
@@ -126,8 +123,7 @@ describe('TagInput – removing tags', () => {
|
||||
|
||||
it('removes the last tag on Backspace when the input is empty', async () => {
|
||||
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.click();
|
||||
(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();
|
||||
@@ -181,7 +177,8 @@ describe('TagInput – autocomplete', () => {
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fa');
|
||||
await waitForDebounce();
|
||||
await page.getByRole('option', { name: 'Familie' }).click();
|
||||
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' });
|
||||
|
||||
@@ -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,22 @@ 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;
|
||||
@@ -164,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;
|
||||
@@ -196,6 +228,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons/{id}/received-documents": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getPersonReceivedDocuments"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons/{id}/documents": {
|
||||
parameters: {
|
||||
query?: 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,28 @@ 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?: {
|
||||
@@ -789,11 +933,13 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getCurrentUser: {
|
||||
getUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
@@ -809,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?: {
|
||||
@@ -831,6 +997,28 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getPersonReceivedDocuments: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getPersonDocuments: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -853,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;
|
||||
@@ -948,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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { sortDocumentsByDate } from './sort';
|
||||
|
||||
const doc = (id: string, documentDate: string | null) =>
|
||||
({ id, documentDate } as { id: string; documentDate: string | null });
|
||||
({ id, documentDate }) as { id: string; documentDate: string | null };
|
||||
|
||||
describe('sortDocumentsByDate', () => {
|
||||
it('sorts DESC by default — newest first', () => {
|
||||
|
||||
@@ -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,121 +1,196 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
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 } = $props();
|
||||
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 locales = ['DE', 'EN', 'ES'] as const;
|
||||
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
|
||||
const activeLocale = $derived(getLocale().toUpperCase());
|
||||
|
||||
const isAdmin = $derived(page.data.user?.groups.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN')));
|
||||
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; });
|
||||
// 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-white" data-hydrated={hydrated || undefined}>
|
||||
{#if !page.url.pathname.startsWith('/login')}
|
||||
<header class="sticky top-0 z-50 border-b border-gray-100 bg-white">
|
||||
<!-- De Gruyter Brill purple accent strip -->
|
||||
<div class="h-1 bg-brand-purple"></div>
|
||||
|
||||
{#if !page.url.pathname.startsWith('/login')}
|
||||
<header class="bg-white border-b border-gray-100 sticky top-0 z-50">
|
||||
<!-- 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">
|
||||
<div class="mr-10 flex flex-shrink-0 items-center">
|
||||
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans text-xl font-bold tracking-widest text-brand-navy uppercase"
|
||||
>Familienarchiv</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
|
||||
<!-- Logo & Nav -->
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0 flex items-center mr-10">
|
||||
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans font-bold text-xl tracking-widest text-brand-navy uppercase">Familienarchiv</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav class="hidden sm:flex sm:space-x-1 items-center">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
|
||||
<nav class="hidden items-center sm:flex sm:space-x-1">
|
||||
<a
|
||||
href="/"
|
||||
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')
|
||||
? 'text-brand-navy bg-brand-purple/15 rounded'
|
||||
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
|
||||
>
|
||||
{m.nav_documents()}
|
||||
</a>
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.nav_documents()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/persons"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
|
||||
<a
|
||||
href="/persons"
|
||||
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')
|
||||
? 'text-brand-navy bg-brand-purple/15 rounded'
|
||||
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
|
||||
>
|
||||
{m.nav_persons()}
|
||||
</a>
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.nav_persons()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/conversations"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
|
||||
<a
|
||||
href="/conversations"
|
||||
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')
|
||||
? 'text-brand-navy bg-brand-purple/15 rounded'
|
||||
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
|
||||
>
|
||||
{m.nav_conversations()}
|
||||
</a>
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.nav_conversations()}
|
||||
</a>
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
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')
|
||||
? 'text-brand-navy bg-brand-purple/15 rounded'
|
||||
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
|
||||
>
|
||||
{m.nav_admin()}
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.nav_admin()}
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Right Side -->
|
||||
<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}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLocale(localeMap[locale])}
|
||||
class="text-xs font-sans tracking-widest px-1.5 py-1 transition-colors
|
||||
<!-- Right Side -->
|
||||
<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>
|
||||
<form action="/logout" method="POST" use:enhance>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-gray-400 hover:text-brand-navy font-bold uppercase font-sans tracking-widest px-3 py-2 transition-colors"
|
||||
>
|
||||
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg" alt="" aria-hidden="true" class="w-4 h-4 opacity-50" />
|
||||
{m.nav_logout()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
>
|
||||
{locale}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
<!-- 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}
|
||||
|
||||
<main class="py-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
{#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>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<main class="py-6">
|
||||
{@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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ 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';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
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 || ''));
|
||||
@@ -27,7 +30,7 @@ const hasAdvancedFilters = (filters: typeof data.filters) =>
|
||||
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
|
||||
|
||||
function triggerSearch() {
|
||||
const params = new URLSearchParams();
|
||||
const params = new SvelteURLSearchParams();
|
||||
|
||||
if (q) params.set('q', q);
|
||||
if (from) params.set('from', from);
|
||||
@@ -59,9 +62,10 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Sync local state with server data after navigation
|
||||
// Sync local state with server data after navigation.
|
||||
// Guard q: skip overwrite while the user is actively typing in the search field.
|
||||
$effect(() => {
|
||||
q = data.filters?.q || '';
|
||||
if (!qFocused) q = data.filters?.q || '';
|
||||
from = data.filters?.from || '';
|
||||
to = data.filters?.to || '';
|
||||
senderId = data.filters?.senderId || '';
|
||||
@@ -83,11 +87,18 @@ $effect(() => {
|
||||
type="text"
|
||||
bind:value={q}
|
||||
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">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg" alt="" aria-hidden="true" class="h-4 w-4 opacity-40" />
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 opacity-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +107,12 @@ $effect(() => {
|
||||
onclick={() => (showAdvanced = !showAdvanced)}
|
||||
class="flex items-center gap-2 border border-gray-300 bg-gray-50 px-4 py-2.5 text-sm font-bold tracking-wide text-gray-600 uppercase transition hover:bg-gray-100 hover:text-brand-navy"
|
||||
>
|
||||
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg" alt="" aria-hidden="true" class="h-4 w-4 transform transition-transform duration-200 {showAdvanced ? 'rotate-180' : ''}" />
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 transform transition-transform duration-200 {showAdvanced ? 'rotate-180' : ''}"
|
||||
/>
|
||||
{m.docs_btn_filter()}
|
||||
</button>
|
||||
|
||||
@@ -106,7 +122,12 @@ $effect(() => {
|
||||
class="flex items-center justify-center border border-transparent px-3 py-2.5 text-gray-400 transition hover:text-red-500"
|
||||
title={m.docs_btn_reset_title()}
|
||||
>
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Close-MD.svg" alt="" aria-hidden="true" class="h-5 w-5 opacity-40" />
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Close-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 opacity-40"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -191,13 +212,20 @@ $effect(() => {
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<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 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 -->
|
||||
@@ -208,7 +236,7 @@ $effect(() => {
|
||||
</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">
|
||||
@@ -237,12 +265,22 @@ $effect(() => {
|
||||
<!-- Metadata Row -->
|
||||
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg" alt="" aria-hidden="true" class="mr-1.5 h-4 w-4" />
|
||||
{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">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg" alt="" aria-hidden="true" class="mr-1.5 h-4 w-4" />
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="mr-1.5 h-4 w-4"
|
||||
/>
|
||||
{doc.location}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -281,7 +319,7 @@ $effect(() => {
|
||||
<!-- 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 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"
|
||||
@@ -298,7 +336,12 @@ $effect(() => {
|
||||
<div
|
||||
class="hidden items-center text-gray-300 transition-colors group-hover:text-brand-mint sm:flex"
|
||||
>
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg" alt="" aria-hidden="true" class="h-6 w-6" />
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -311,7 +354,12 @@ $effect(() => {
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-sand/30"
|
||||
>
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg" alt="" aria-hidden="true" class="h-6 w-6" />
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
|
||||
@@ -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,67 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { enhance } from '$app/forms';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form } = $props();
|
||||
let { data, form } = $props();
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
||||
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
||||
|
||||
function startEditTag(tag: { id: string; name: string }) {
|
||||
editingTagId = tag.id;
|
||||
editingTagName = tag.name;
|
||||
}
|
||||
function startEditTag(tag: { id: string; name: string }) {
|
||||
editingTagId = tag.id;
|
||||
editingTagName = tag.name;
|
||||
}
|
||||
|
||||
function cancelEditTag() {
|
||||
editingTagId = null;
|
||||
editingTagName = '';
|
||||
}
|
||||
function cancelEditTag() {
|
||||
editingTagId = null;
|
||||
editingTagName = '';
|
||||
}
|
||||
|
||||
function startEditUser(id: string) {
|
||||
editingUserId = id;
|
||||
}
|
||||
function startEditUser(id: string) {
|
||||
editingUserId = id;
|
||||
}
|
||||
|
||||
function cancelEditUser() {
|
||||
editingUserId = null;
|
||||
}
|
||||
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">{m.admin_heading()}</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'}"
|
||||
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'}"
|
||||
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'}"
|
||||
@@ -71,40 +71,40 @@
|
||||
</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">
|
||||
<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"
|
||||
<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"
|
||||
<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"
|
||||
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}
|
||||
<input
|
||||
type="hidden"
|
||||
@@ -119,9 +119,9 @@
|
||||
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: { id: string }) => g.id === group.id)}
|
||||
@@ -130,10 +130,10 @@
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-[10px] text-gray-400 mt-1">{m.admin_multiselect_hint()}</p>
|
||||
<p class="mt-1 text-[10px] text-gray-400">{m.admin_multiselect_hint()}</p>
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right align-top">
|
||||
<td class="px-6 py-4 text-right align-top whitespace-nowrap">
|
||||
<form
|
||||
id="edit-form-{user.id}"
|
||||
method="POST"
|
||||
@@ -149,20 +149,20 @@
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={m.admin_password_placeholder()}
|
||||
class="w-32 py-1 px-2 text-xs border border-brand-mint rounded"
|
||||
class="w-32 rounded border border-brand-mint px-2 py-1 text-xs"
|
||||
/>
|
||||
|
||||
<div class="flex gap-2 mt-1">
|
||||
<div class="mt-1 flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-green-600 text-white px-2 py-1 rounded text-xs font-bold uppercase hover:bg-green-700"
|
||||
class="rounded bg-green-600 px-2 py-1 text-xs font-bold text-white uppercase hover:bg-green-700"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelEditUser}
|
||||
class="bg-gray-200 text-gray-600 px-2 py-1 rounded text-xs font-bold uppercase hover:bg-gray-300"
|
||||
class="rounded bg-gray-200 px-2 py-1 text-xs font-bold text-gray-600 uppercase hover:bg-gray-300"
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
@@ -171,15 +171,15 @@
|
||||
</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>
|
||||
@@ -189,11 +189,11 @@
|
||||
{/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">
|
||||
<button
|
||||
onclick={() => startEditUser(user.id)}
|
||||
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
|
||||
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
@@ -213,10 +213,10 @@
|
||||
>
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
<button
|
||||
class="text-gray-300 hover:text-red-600 transition-colors p-1"
|
||||
class="p-1 text-gray-300 transition-colors hover:text-red-600"
|
||||
title={m.admin_btn_delete_user_title()}
|
||||
>
|
||||
<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"
|
||||
@@ -235,66 +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">
|
||||
<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={m.admin_col_password()}
|
||||
required
|
||||
class="rounded border-gray-300 text-sm w-full"
|
||||
class="w-full rounded border-gray-300 text-sm"
|
||||
/>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<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={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">{m.admin_multiselect_hint_full()}</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"
|
||||
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'}
|
||||
<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">
|
||||
<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="text-xs text-yellow-800 mt-1">
|
||||
<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"
|
||||
@@ -304,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={m.btn_save()} class="text-green-600 hover:text-green-800"
|
||||
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -328,7 +328,7 @@
|
||||
onclick={cancelEditTag}
|
||||
aria-label={m.btn_cancel()}
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -339,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
|
||||
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"
|
||||
@@ -375,8 +375,11 @@
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="id" value={tag.id} />
|
||||
<button aria-label={m.admin_btn_delete_tag_label()} class="p-1 text-gray-400 hover:text-red-600">
|
||||
<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"
|
||||
@@ -393,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">
|
||||
<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"
|
||||
<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"
|
||||
<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"
|
||||
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 -->
|
||||
@@ -427,7 +430,7 @@
|
||||
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} />
|
||||
|
||||
@@ -436,13 +439,13 @@
|
||||
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>
|
||||
|
||||
<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"
|
||||
>
|
||||
@@ -451,7 +454,7 @@
|
||||
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>
|
||||
@@ -459,8 +462,12 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 self-start sm:self-center">
|
||||
<button type="submit" aria-label={m.btn_save()} class="text-green-600 hover:text-green-800 p-1">
|
||||
<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"
|
||||
@@ -473,9 +480,9 @@
|
||||
type="button"
|
||||
onclick={cancelEditGroup}
|
||||
aria-label={m.btn_cancel()}
|
||||
class="text-gray-400 hover:text-red-500 p-1"
|
||||
class="p-1 text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -489,28 +496,28 @@
|
||||
</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
|
||||
onclick={() => startEditGroup(group.id)}
|
||||
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
|
||||
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
@@ -529,10 +536,10 @@
|
||||
>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<button
|
||||
class="text-gray-300 hover:text-red-600 p-1 transition-colors"
|
||||
class="p-1 text-gray-300 transition-colors hover:text-red-600"
|
||||
title={m.btn_delete()}
|
||||
>
|
||||
<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"
|
||||
@@ -551,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">
|
||||
<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={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>
|
||||
@@ -587,7 +594,7 @@
|
||||
|
||||
<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"
|
||||
>
|
||||
{m.btn_create()}
|
||||
</button>
|
||||
|
||||
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') ?? ''
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -3,33 +3,32 @@ 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 = `${env.API_INTERNAL_URL || '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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,34 +3,33 @@ 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 = `${env.API_INTERNAL_URL || '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,87 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
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';
|
||||
|
||||
let { data } = $props();
|
||||
let { data } = $props();
|
||||
|
||||
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));
|
||||
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));
|
||||
|
||||
// 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;
|
||||
});
|
||||
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);
|
||||
|
||||
function applyFilters() {
|
||||
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(`/conversations?${params.toString()}`, { keepFocus: true });
|
||||
}
|
||||
// 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;
|
||||
});
|
||||
|
||||
function toggleSort() {
|
||||
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
|
||||
applyFilters();
|
||||
}
|
||||
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 swapPersons() {
|
||||
const tmp = senderId;
|
||||
senderId = receiverId;
|
||||
receiverId = tmp;
|
||||
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">{m.conv_heading()}</h1>
|
||||
<p class="text-brand-navy/60 font-sans text-sm mt-2">
|
||||
<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={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={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"
|
||||
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||
>{m.conv_label_from()}</label
|
||||
>
|
||||
<input
|
||||
@@ -89,7 +149,7 @@
|
||||
type="date"
|
||||
bind:value={fromDate}
|
||||
onchange={() => applyFilters()}
|
||||
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
|
||||
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -97,7 +157,7 @@
|
||||
<div>
|
||||
<label
|
||||
for="dateTo"
|
||||
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
|
||||
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||
>{m.conv_label_to()}</label
|
||||
>
|
||||
<input
|
||||
@@ -105,7 +165,7 @@
|
||||
type="date"
|
||||
bind:value={toDate}
|
||||
onchange={() => applyFilters()}
|
||||
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
|
||||
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -113,12 +173,12 @@
|
||||
<div>
|
||||
<button
|
||||
onclick={toggleSort}
|
||||
class="w-full flex items-center justify-center h-[42px] border border-brand-sand text-xs font-bold uppercase tracking-wide text-brand-navy hover:bg-brand-navy hover:text-white transition-colors"
|
||||
class="flex h-[42px] w-full items-center justify-center border border-brand-sand text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
||||
>
|
||||
<span class="mr-2">{m.conv_sort_label()}</span>
|
||||
<span>{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"
|
||||
@@ -136,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"
|
||||
@@ -148,44 +208,80 @@
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
<p class="text-brand-navy font-serif text-lg">{m.conv_empty_heading()}</p>
|
||||
<p class="text-gray-500 font-sans text-sm mt-1">{m.conv_empty_text()}</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 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">{m.conv_no_results_heading()}</p>
|
||||
<p class="text-gray-400 text-sm mt-2">{m.conv_no_results_text()}</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 -->
|
||||
<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>
|
||||
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="flex flex-col gap-4 relative z-10">
|
||||
{#each data.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 -->
|
||||
<div class="flex-shrink-0 mt-auto mb-1 hidden sm:block">
|
||||
<div class="mt-auto mb-1 hidden flex-shrink-0 sm:block">
|
||||
<div
|
||||
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]}
|
||||
@@ -198,15 +294,15 @@
|
||||
<!-- BUBBLE CARD -->
|
||||
<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'}"
|
||||
>
|
||||
@@ -215,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'}"
|
||||
@@ -226,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>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { setLocale } from '$lib/paraglide/runtime';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { setLocale } from '$lib/paraglide/runtime';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<h1>{m.nav_documents()}</h1>
|
||||
<div>
|
||||
<button onclick={() => setLocale('en')}>en</button>
|
||||
<button onclick={() => setLocale('es')}>es</button>
|
||||
<button onclick={() => setLocale('de')}>de</button>
|
||||
</div><p>
|
||||
If you use VSCode, install the <a href="https://marketplace.visualstudio.com/items?itemName=inlang.vs-code-extension" target="_blank">Sherlock i18n extension</a> for a better i18n experience.
|
||||
</div>
|
||||
<p>
|
||||
If you use VSCode, install the <a
|
||||
href="https://marketplace.visualstudio.com/items?itemName=inlang.vs-code-extension"
|
||||
target="_blank">Sherlock i18n extension</a
|
||||
> for a better i18n experience.
|
||||
</p>
|
||||
|
||||
@@ -3,17 +3,17 @@ import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
export async function load({ params, fetch }) {
|
||||
const { id } = params;
|
||||
const api = createApiClient(fetch);
|
||||
const { id } = params;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const result = await api.GET('/api/documents/{id}', { params: { path: { id } } });
|
||||
const result = await api.GET('/api/documents/{id}', { params: { path: { id } } });
|
||||
|
||||
if (result.response.status === 401) throw redirect(302, '/login');
|
||||
if (result.response.status === 401) throw redirect(302, '/login');
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
return { document: result.data! };
|
||||
return { document: result.data! };
|
||||
}
|
||||
|
||||
@@ -1,175 +1,216 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
|
||||
let { data } = $props();
|
||||
let { data } = $props();
|
||||
|
||||
const doc = $derived(data.document);
|
||||
const doc = $derived(data.document);
|
||||
|
||||
let fileUrl = $state('');
|
||||
let isLoading = $state(false);
|
||||
let error = $state('');
|
||||
let fileUrl = $state('');
|
||||
let isLoading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (doc?.id && doc?.filePath) {
|
||||
loadFile(doc.id);
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (doc?.id && doc?.filePath) {
|
||||
loadFile(doc.id);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFile(id: string) {
|
||||
isLoading = true;
|
||||
error = '';
|
||||
fileUrl = '';
|
||||
async function loadFile(id: string) {
|
||||
isLoading = true;
|
||||
error = '';
|
||||
fileUrl = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/documents/${id}/file`);
|
||||
try {
|
||||
const response = await fetch(`/api/documents/${id}/file`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) throw new Error('Nicht eingeloggt');
|
||||
throw new Error('Fehler beim Laden der Datei');
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) throw new Error('Nicht eingeloggt');
|
||||
throw new Error('Fehler beim Laden der Datei');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
fileUrl = URL.createObjectURL(blob);
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error = m.doc_file_error_preview();
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
const blob = await response.blob();
|
||||
fileUrl = URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error = m.doc_file_error_preview();
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-screen flex flex-col bg-white">
|
||||
<div class="flex h-screen flex-col bg-white">
|
||||
<!-- Top Bar -->
|
||||
<div
|
||||
class="bg-white border-b border-brand-sand px-6 py-4 flex items-center justify-between z-10 shadow-sm"
|
||||
class="z-10 flex items-center justify-between border-b border-brand-sand bg-white px-6 py-4 shadow-sm"
|
||||
>
|
||||
<div class="flex items-center gap-6 overflow-hidden">
|
||||
<a
|
||||
href="/"
|
||||
class="group flex-shrink-0 flex items-center gap-2 text-sm font-sans font-medium text-gray-500 hover:text-brand-navy transition-colors"
|
||||
class="group flex flex-shrink-0 items-center gap-2 font-sans text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-brand-sand group-hover:bg-brand-mint flex items-center justify-center transition-colors"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-sand transition-colors group-hover:bg-brand-mint"
|
||||
>
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg" alt="" aria-hidden="true" class="w-4 h-4" />
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<span>{m.btn_back()}</span>
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-4 overflow-hidden border-l border-gray-200 pl-6">
|
||||
<h1 class="text-xl font-serif text-brand-navy truncate" title={doc.title}>
|
||||
<h1 class="truncate font-serif text-xl text-brand-navy" title={doc.title}>
|
||||
{doc.title || doc.originalFilename}
|
||||
</h1>
|
||||
<span
|
||||
class="flex-shrink-0 px-3 py-1 rounded-full text-xs font-sans font-bold tracking-wide uppercase
|
||||
class="flex-shrink-0 rounded-full px-3 py-1 font-sans text-xs font-bold tracking-wide uppercase
|
||||
{doc.status === 'UPLOADED'
|
||||
? 'bg-brand-mint/30 text-brand-navy border border-brand-mint'
|
||||
: 'bg-yellow-100 text-yellow-800 border border-yellow-200'}"
|
||||
? 'border border-brand-mint bg-brand-mint/30 text-brand-navy'
|
||||
: 'border border-yellow-200 bg-yellow-100 text-yellow-800'}"
|
||||
>
|
||||
{doc.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-shrink-0 ml-4 font-sans">
|
||||
<a
|
||||
href="/documents/{doc.id}/edit"
|
||||
class="text-brand-navy bg-transparent border border-brand-navy hover:bg-brand-navy hover:text-white px-4 py-2 rounded text-sm font-medium transition flex items-center gap-2"
|
||||
>
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg" alt="" aria-hidden="true" class="w-4 h-4" />
|
||||
{m.btn_edit()}
|
||||
</a>
|
||||
<div class="ml-4 flex flex-shrink-0 items-center gap-3 font-sans">
|
||||
{#if data.canWrite}
|
||||
<a
|
||||
href="/documents/{doc.id}/edit"
|
||||
class="flex items-center gap-2 rounded border border-brand-navy bg-transparent px-4 py-2 text-sm font-medium text-brand-navy transition hover:bg-brand-navy hover:text-white"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.btn_edit()}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath}
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={doc.originalFilename}
|
||||
class="text-brand-navy bg-brand-sand/50 hover:bg-brand-mint border border-transparent p-2 rounded transition"
|
||||
class="rounded border border-transparent bg-brand-sand/50 p-2 text-brand-navy transition hover:bg-brand-mint"
|
||||
title={m.doc_download_title()}
|
||||
>
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- LEFT SIDEBAR: METADATA -->
|
||||
<aside
|
||||
class="w-96 bg-white border-r border-brand-sand overflow-y-auto p-8 flex-shrink-0 custom-scrollbar"
|
||||
class="custom-scrollbar w-96 flex-shrink-0 overflow-y-auto border-r border-brand-sand bg-white p-8"
|
||||
>
|
||||
<div class="space-y-10">
|
||||
<!-- 1. DETAILS GROUP -->
|
||||
<div>
|
||||
<h3
|
||||
class="text-xs font-sans font-bold text-brand-navy uppercase tracking-widest mb-4 border-b border-brand-sand pb-2"
|
||||
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||
>
|
||||
{m.doc_section_details()}
|
||||
</h3>
|
||||
<div class="space-y-5">
|
||||
<!-- Date -->
|
||||
<div class="flex items-start group">
|
||||
<span class="text-brand-mint w-8 mt-0.5">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
|
||||
<div class="group flex items-start">
|
||||
<span class="mt-0.5 w-8 text-brand-mint">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-brand-navy">
|
||||
{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>
|
||||
<span class="text-xs font-sans text-gray-500">{m.doc_label_document_date()}</span>
|
||||
<span class="font-sans text-xs text-gray-500">{m.doc_label_document_date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creation Location -->
|
||||
<div class="flex items-start group">
|
||||
<span class="text-brand-mint w-8 mt-0.5">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
|
||||
<div class="group flex items-start">
|
||||
<span class="mt-0.5 w-8 text-brand-mint">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-brand-navy">
|
||||
{doc.location ? doc.location : '—'}
|
||||
</span>
|
||||
<span class="text-xs font-sans text-gray-500">{m.doc_label_creation_location()}</span>
|
||||
<span class="font-sans text-xs text-gray-500"
|
||||
>{m.doc_label_creation_location()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Physical Archive Location -->
|
||||
{#if doc.documentLocation}
|
||||
<div class="flex items-start group">
|
||||
<span class="text-brand-mint w-8 mt-0.5">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
|
||||
<div class="group flex items-start">
|
||||
<span class="mt-0.5 w-8 text-brand-mint">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-brand-navy">
|
||||
{doc.documentLocation}
|
||||
</span>
|
||||
<span class="text-xs font-sans text-gray-500">{m.doc_label_archive_location_original()}</span>
|
||||
<span class="font-sans text-xs text-gray-500"
|
||||
>{m.doc_label_archive_location_original()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- TAGS / SCHLAGWORTE -->
|
||||
{#if doc.tags && doc.tags.length > 0}
|
||||
<div class="flex items-start group">
|
||||
<span class="text-brand-mint w-8 mt-0.5">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
|
||||
<div class="group flex items-start">
|
||||
<span class="mt-0.5 w-8 text-brand-mint">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<div class="flex flex-wrap gap-2 mb-1">
|
||||
{#each doc.tags as tag}
|
||||
<div class="mb-1 flex flex-wrap gap-2">
|
||||
{#each doc.tags as tag (tag.id)}
|
||||
<a
|
||||
href="/?tag={encodeURIComponent(tag.name)}"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold uppercase tracking-wide bg-brand-sand/50 text-brand-navy hover:bg-brand-navy hover:text-white transition-colors"
|
||||
class="inline-flex items-center rounded bg-brand-sand/50 px-2 py-0.5 text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
||||
title={m.doc_tag_filter_title({ name: tag.name })}
|
||||
>
|
||||
{tag.name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="text-xs font-sans text-gray-500">{m.form_label_tags()}</span>
|
||||
<span class="font-sans text-xs text-gray-500">{m.form_label_tags()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -179,58 +220,64 @@
|
||||
<!-- 2. PERSONEN GROUP -->
|
||||
<div>
|
||||
<h3
|
||||
class="text-xs font-sans font-bold text-brand-navy uppercase tracking-widest mb-4 border-b border-brand-sand pb-2"
|
||||
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||
>
|
||||
{m.doc_section_persons()}
|
||||
</h3>
|
||||
|
||||
<div class="mb-6">
|
||||
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">{m.form_label_sender()}</span>
|
||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||
>{m.form_label_sender()}</span
|
||||
>
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/persons/{doc.sender.id}"
|
||||
class="block p-3 rounded border border-brand-sand bg-brand-sand/20 hover:border-brand-mint hover:bg-brand-mint/10 transition group"
|
||||
class="group block rounded border border-brand-sand bg-brand-sand/20 p-3 transition hover:border-brand-mint hover:bg-brand-mint/10"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-brand-navy text-white flex items-center justify-center font-serif text-sm"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-serif text-sm text-white"
|
||||
>
|
||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
class="font-serif text-brand-navy group-hover:underline decoration-brand-mint underline-offset-2"
|
||||
class="font-serif text-brand-navy decoration-brand-mint underline-offset-2 group-hover:underline"
|
||||
>
|
||||
{doc.sender.firstName}
|
||||
{doc.sender.lastName}
|
||||
</p>
|
||||
{#if doc.sender.alias}
|
||||
<p class="text-xs font-sans text-gray-500">{doc.sender.alias}</p>
|
||||
<p class="font-sans text-xs text-gray-500">{doc.sender.alias}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-sm font-serif text-gray-400 italic">{m.doc_sender_not_specified()}</span>
|
||||
<span class="font-serif text-sm text-gray-400 italic"
|
||||
>{m.doc_sender_not_specified()}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">{m.form_label_receivers()}</span>
|
||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||
>{m.form_label_receivers()}</span
|
||||
>
|
||||
{#if doc.receivers && doc.receivers.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each doc.receivers as receiver}
|
||||
{#each doc.receivers as receiver (receiver.id)}
|
||||
<div
|
||||
class="flex items-center justify-between p-3 rounded border border-brand-sand bg-white hover:border-brand-navy transition group"
|
||||
class="group flex items-center justify-between rounded border border-brand-sand bg-white p-3 transition hover:border-brand-navy"
|
||||
>
|
||||
<a href="/persons/{receiver.id}" class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-gray-100 text-gray-500 flex items-center justify-center text-xs font-serif"
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-gray-100 font-serif text-xs text-gray-500"
|
||||
>
|
||||
{receiver.firstName[0]}{receiver.lastName[0]}
|
||||
</div>
|
||||
<span
|
||||
class="font-serif text-sm text-brand-navy group-hover:text-brand-navy truncate"
|
||||
class="truncate font-serif text-sm text-brand-navy group-hover:text-brand-navy"
|
||||
>
|
||||
{receiver.firstName}
|
||||
{receiver.lastName}
|
||||
@@ -240,17 +287,22 @@
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
|
||||
class="text-gray-300 hover:text-brand-mint transition"
|
||||
class="text-gray-300 transition hover:text-brand-mint"
|
||||
title={m.doc_conversation_title()}
|
||||
>
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-sm font-serif text-gray-400 italic">{m.doc_no_receivers()}</span>
|
||||
<span class="font-serif text-sm text-gray-400 italic">{m.doc_no_receivers()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,7 +311,7 @@
|
||||
{#if doc.summary || doc.transcription}
|
||||
<div>
|
||||
<h3
|
||||
class="text-xs font-sans font-bold text-brand-navy uppercase tracking-widest mb-4 border-b border-brand-sand pb-2"
|
||||
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||
>
|
||||
{m.doc_section_content()}
|
||||
</h3>
|
||||
@@ -267,9 +319,11 @@
|
||||
<div class="space-y-6">
|
||||
{#if doc.summary}
|
||||
<div>
|
||||
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">{m.doc_label_summary()}</span>
|
||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||
>{m.doc_label_summary()}</span
|
||||
>
|
||||
<div
|
||||
class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap"
|
||||
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
|
||||
>
|
||||
{doc.summary}
|
||||
</div>
|
||||
@@ -278,9 +332,11 @@
|
||||
|
||||
{#if doc.transcription}
|
||||
<div>
|
||||
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">{m.form_label_transcription()}</span>
|
||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||
>{m.form_label_transcription()}</span
|
||||
>
|
||||
<div
|
||||
class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap"
|
||||
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
|
||||
>
|
||||
{doc.transcription}
|
||||
</div>
|
||||
@@ -291,19 +347,19 @@
|
||||
{/if}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="pt-4 border-t border-brand-sand text-[10px] font-sans text-gray-400">
|
||||
<div class="border-t border-brand-sand pt-4 font-sans text-[10px] text-gray-400">
|
||||
<p class="truncate">ID: {doc.id}</p>
|
||||
<p class="truncate mt-1">{doc.originalFilename}</p>
|
||||
<p class="mt-1 truncate">{doc.originalFilename}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- RIGHT: PREVIEW AREA -->
|
||||
<main class="flex-1 bg-[#2A2A2A] relative flex flex-col items-center justify-center">
|
||||
<main class="relative flex flex-1 flex-col items-center justify-center bg-[#2A2A2A]">
|
||||
{#if isLoading}
|
||||
<div class="text-brand-mint flex flex-col items-center">
|
||||
<div class="flex flex-col items-center text-brand-mint">
|
||||
<svg
|
||||
class="animate-spin h-8 w-8 mb-4"
|
||||
class="mb-4 h-8 w-8 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -319,13 +375,13 @@
|
||||
<span class="font-sans text-sm tracking-wide">{m.doc_loading()}</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-gray-400 text-center px-4">
|
||||
<p class="font-serif mb-2">{error}</p>
|
||||
<div class="px-4 text-center text-gray-400">
|
||||
<p class="mb-2 font-serif">{error}</p>
|
||||
{#if doc.filePath}
|
||||
<a
|
||||
href={`/api/documents/${doc.id}/file`}
|
||||
target="_blank"
|
||||
class="underline hover:text-white text-sm"
|
||||
class="text-sm underline hover:text-white"
|
||||
>
|
||||
{m.doc_download_link()}
|
||||
</a>
|
||||
@@ -333,8 +389,13 @@
|
||||
</div>
|
||||
{:else if !doc.filePath}
|
||||
<div class="flex flex-col items-center text-gray-400">
|
||||
<div class="bg-white/5 p-8 rounded-full mb-6">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg" alt="" aria-hidden="true" class="w-12 h-12 opacity-50 invert" />
|
||||
<div class="mb-6 rounded-full bg-white/5 p-8">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-12 w-12 opacity-50 invert"
|
||||
/>
|
||||
</div>
|
||||
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
||||
</div>
|
||||
@@ -342,14 +403,14 @@
|
||||
<iframe
|
||||
src={fileUrl}
|
||||
title={m.doc_preview_iframe_title()}
|
||||
class="w-full h-full border-none bg-white"
|
||||
class="h-full w-full border-none bg-white"
|
||||
></iframe>
|
||||
{:else if fileUrl}
|
||||
<div class="w-full h-full flex items-center justify-center overflow-auto p-8">
|
||||
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={m.doc_image_alt()}
|
||||
class="max-w-full max-h-full object-contain shadow-2xl"
|
||||
class="max-h-full max-w-full object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -3,46 +3,60 @@ import { env } from '$env/dynamic/private';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { parseBackendError, getErrorMessage } from '$lib/errors';
|
||||
|
||||
export async function load({ params, fetch }) {
|
||||
const { id } = params;
|
||||
const api = createApiClient(fetch);
|
||||
export async function load({
|
||||
params,
|
||||
fetch,
|
||||
locals
|
||||
}: {
|
||||
params: { id: string };
|
||||
fetch: typeof globalThis.fetch;
|
||||
locals: App.Locals;
|
||||
}) {
|
||||
const canWrite =
|
||||
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false;
|
||||
if (!canWrite) throw error(403, 'Forbidden');
|
||||
|
||||
const [docResult, personsResult] = await Promise.all([
|
||||
api.GET('/api/documents/{id}', { params: { path: { id } } }),
|
||||
api.GET('/api/persons')
|
||||
]);
|
||||
const { id } = params;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
if (!docResult.response.ok) {
|
||||
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||
throw error(docResult.response.status, getErrorMessage(code));
|
||||
}
|
||||
if (!personsResult.response.ok) {
|
||||
throw error(personsResult.response.status, getErrorMessage('INTERNAL_ERROR'));
|
||||
}
|
||||
const [docResult, personsResult] = await Promise.all([
|
||||
api.GET('/api/documents/{id}', { params: { path: { id } } }),
|
||||
api.GET('/api/persons')
|
||||
]);
|
||||
|
||||
return {
|
||||
document: docResult.data!,
|
||||
persons: personsResult.data
|
||||
};
|
||||
if (!docResult.response.ok) {
|
||||
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||
throw error(docResult.response.status, getErrorMessage(code));
|
||||
}
|
||||
if (!personsResult.response.ok) {
|
||||
throw error(personsResult.response.status, getErrorMessage('INTERNAL_ERROR'));
|
||||
}
|
||||
|
||||
return {
|
||||
document: docResult.data!,
|
||||
persons: personsResult.data
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, params, fetch }) => {
|
||||
// Raw fetch is used here because FormData multipart bodies are passed through
|
||||
// directly from the browser without transformation.
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const formData = await request.formData();
|
||||
default: async ({ request, params, fetch }) => {
|
||||
// Raw fetch is used here because FormData multipart bodies are passed through
|
||||
// directly from the browser without transformation.
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const formData = await request.formData();
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
|
||||
method: 'PUT',
|
||||
body: formData
|
||||
});
|
||||
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
|
||||
method: 'PUT',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const backendError = await parseBackendError(res);
|
||||
return fail(res.status, { error: getErrorMessage(backendError?.code) });
|
||||
}
|
||||
if (!res.ok) {
|
||||
const backendError = await parseBackendError(res);
|
||||
return fail(res.status, { error: getErrorMessage(backendError?.code) });
|
||||
}
|
||||
|
||||
throw redirect(303, `/documents/${params.id}`);
|
||||
}
|
||||
throw redirect(303, `/documents/${params.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,228 +1,263 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import TagInput from '$lib/components/TagInput.svelte';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { isoToGerman, germanToIso } from '$lib/utils';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { enhance } from '$app/forms';
|
||||
import TagInput from '$lib/components/TagInput.svelte';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { isoToGerman, germanToIso } from '$lib/utils';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form } = $props();
|
||||
let { data, form } = $props();
|
||||
|
||||
let { document: doc } = untrack(() => data);
|
||||
let tags = $state(doc.tags ? doc.tags.map((t: { name: string }) => t.name) : []);
|
||||
let senderId = $state(doc.sender?.id ?? '');
|
||||
let selectedReceivers = $state(doc.receivers ?? []);
|
||||
let { document: doc } = untrack(() => data);
|
||||
let tags = $state(doc.tags ? doc.tags.map((t: { name: string }) => t.name) : []);
|
||||
let senderId = $state(doc.sender?.id ?? '');
|
||||
let selectedReceivers = $state(doc.receivers ?? []);
|
||||
|
||||
let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
|
||||
let dateIso = $state(doc.documentDate ?? '');
|
||||
let dateDirty = $state(false);
|
||||
let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
|
||||
let dateIso = $state(doc.documentDate ?? '');
|
||||
let dateDirty = $state(false);
|
||||
|
||||
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||
|
||||
function handleDateInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const digits = input.value.replace(/\D/g, '').slice(0, 8);
|
||||
let formatted: string;
|
||||
if (digits.length <= 2) {
|
||||
formatted = digits;
|
||||
} else if (digits.length <= 4) {
|
||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
} else {
|
||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
}
|
||||
input.value = formatted;
|
||||
dateDisplay = formatted;
|
||||
dateIso = germanToIso(formatted);
|
||||
dateDirty = true;
|
||||
}
|
||||
function handleDateInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const digits = input.value.replace(/\D/g, '').slice(0, 8);
|
||||
let formatted: string;
|
||||
if (digits.length <= 2) {
|
||||
formatted = digits;
|
||||
} else if (digits.length <= 4) {
|
||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
} else {
|
||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
}
|
||||
input.value = formatted;
|
||||
dateDisplay = formatted;
|
||||
dateIso = germanToIso(formatted);
|
||||
dateDirty = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-4xl mx-auto py-8 px-4">
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<!-- Heading -->
|
||||
<div class="mb-6">
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
/>
|
||||
{m.btn_back_to_document()}
|
||||
</a>
|
||||
<h1 class="font-serif text-3xl text-brand-navy">
|
||||
{m.doc_edit_heading()} —
|
||||
<span class="text-brand-navy/70">{doc.title || doc.originalFilename}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<div class="mb-6">
|
||||
<a href="/documents/{doc.id}" class="inline-flex items-center text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy transition-colors group mb-4">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg" alt="" aria-hidden="true" class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" />
|
||||
{m.btn_back_to_document()}
|
||||
</a>
|
||||
<h1 class="text-3xl font-serif text-brand-navy">
|
||||
{m.doc_edit_heading()} — <span class="text-brand-navy/70">{doc.title || doc.originalFilename}</span>
|
||||
</h1>
|
||||
</div>
|
||||
{#if form?.error}
|
||||
<div class="mb-6 rounded border border-red-200 bg-red-50 p-4 text-red-700">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error}
|
||||
<div class="bg-red-50 text-red-700 border border-red-200 p-4 rounded mb-6">{form.error}</div>
|
||||
{/if}
|
||||
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
||||
<!-- ── Section 1: Wer & Wann ── -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.doc_section_who_when()}
|
||||
</h2>
|
||||
|
||||
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
||||
|
||||
<!-- ── Section 1: Wer & Wann ── -->
|
||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
||||
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">{m.doc_section_who_when()}</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
|
||||
<!-- Datum -->
|
||||
<div>
|
||||
<label for="documentDate" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_date()}</label>
|
||||
<input
|
||||
id="documentDate"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={dateDisplay}
|
||||
oninput={handleDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
class="block w-full rounded border-gray-300 shadow-sm p-2 border text-sm
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<!-- Datum -->
|
||||
<div>
|
||||
<label for="documentDate" class="mb-1 block text-sm font-medium text-gray-700"
|
||||
>{m.form_label_date()}</label
|
||||
>
|
||||
<input
|
||||
id="documentDate"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={dateDisplay}
|
||||
oninput={handleDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm
|
||||
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-brand-navy focus:ring-brand-navy'}"
|
||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||
/>
|
||||
<input type="hidden" name="documentDate" value={dateIso} />
|
||||
{#if dateInvalid}
|
||||
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||
/>
|
||||
<input type="hidden" name="documentDate" value={dateIso} />
|
||||
{#if dateInvalid}
|
||||
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Ort -->
|
||||
<div>
|
||||
<label for="location" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_location()}</label>
|
||||
<input
|
||||
id="location"
|
||||
type="text"
|
||||
name="location"
|
||||
value={doc.location || ''}
|
||||
placeholder={m.form_placeholder_location()}
|
||||
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm"
|
||||
/>
|
||||
</div>
|
||||
<!-- Ort -->
|
||||
<div>
|
||||
<label for="location" class="mb-1 block text-sm font-medium text-gray-700"
|
||||
>{m.form_label_location()}</label
|
||||
>
|
||||
<input
|
||||
id="location"
|
||||
type="text"
|
||||
name="location"
|
||||
value={doc.location || ''}
|
||||
placeholder={m.form_placeholder_location()}
|
||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Absender -->
|
||||
<div>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
label={m.form_label_sender()}
|
||||
bind:value={senderId}
|
||||
initialName={doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : ''}
|
||||
/>
|
||||
</div>
|
||||
<!-- Absender -->
|
||||
<div>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
label={m.form_label_sender()}
|
||||
bind:value={senderId}
|
||||
initialName={doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empfänger -->
|
||||
<div>
|
||||
<p class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_receivers()}</p>
|
||||
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
|
||||
</div>
|
||||
<!-- Empfänger -->
|
||||
<div>
|
||||
<p class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_receivers()}</p>
|
||||
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── Section 2: Beschreibung ── -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.doc_section_description()}
|
||||
</h2>
|
||||
|
||||
<!-- ── Section 2: Beschreibung ── -->
|
||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
||||
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">{m.doc_section_description()}</h2>
|
||||
<div class="space-y-5">
|
||||
<!-- Titel -->
|
||||
<div>
|
||||
<label for="title" class="mb-1 block text-sm font-medium text-gray-700"
|
||||
>{m.form_label_title()} *</label
|
||||
>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
name="title"
|
||||
value={doc.title || ''}
|
||||
required
|
||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Aufbewahrungsort -->
|
||||
<div>
|
||||
<label for="documentLocation" class="mb-1 block text-sm font-medium text-gray-700"
|
||||
>{m.form_label_archive_location()}</label
|
||||
>
|
||||
<input
|
||||
id="documentLocation"
|
||||
type="text"
|
||||
name="documentLocation"
|
||||
value={doc.documentLocation || ''}
|
||||
placeholder={m.form_placeholder_archive_location()}
|
||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">{m.form_helper_archive_location()}</p>
|
||||
</div>
|
||||
|
||||
<!-- Titel -->
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_title()} *</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
name="title"
|
||||
value={doc.title || ''}
|
||||
required
|
||||
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm"
|
||||
/>
|
||||
</div>
|
||||
<!-- Schlagworte -->
|
||||
<div>
|
||||
<p class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_tags()}</p>
|
||||
<TagInput bind:tags={tags} />
|
||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||
</div>
|
||||
|
||||
<!-- Aufbewahrungsort -->
|
||||
<div>
|
||||
<label for="documentLocation" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_archive_location()}</label>
|
||||
<input
|
||||
id="documentLocation"
|
||||
type="text"
|
||||
name="documentLocation"
|
||||
value={doc.documentLocation || ''}
|
||||
placeholder={m.form_placeholder_archive_location()}
|
||||
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">{m.form_helper_archive_location()}</p>
|
||||
</div>
|
||||
<!-- Inhalt -->
|
||||
<div>
|
||||
<label for="summary" class="mb-1 block text-sm font-medium text-gray-700"
|
||||
>{m.form_label_content()}</label
|
||||
>
|
||||
<textarea
|
||||
id="summary"
|
||||
name="summary"
|
||||
rows="5"
|
||||
placeholder={m.form_placeholder_content()}
|
||||
class="block w-full rounded border border-gray-300 p-2 font-serif text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||
>{doc.summary || ''}</textarea
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schlagworte -->
|
||||
<div>
|
||||
<p class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_tags()}</p>
|
||||
<TagInput bind:tags />
|
||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||
</div>
|
||||
<!-- ── Section 3: Transkription ── -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.form_label_transcription()}
|
||||
</h2>
|
||||
<textarea
|
||||
id="transcription"
|
||||
name="transcription"
|
||||
rows="12"
|
||||
placeholder={m.form_placeholder_transcription()}
|
||||
class="block w-full rounded border border-gray-300 p-2 font-serif text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||
>{doc.transcription || ''}</textarea
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Inhalt -->
|
||||
<div>
|
||||
<label for="summary" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_content()}</label>
|
||||
<textarea
|
||||
id="summary"
|
||||
name="summary"
|
||||
rows="5"
|
||||
placeholder={m.form_placeholder_content()}
|
||||
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm font-serif"
|
||||
>{doc.summary || ''}</textarea>
|
||||
</div>
|
||||
<!-- ── Section 4: Datei ── -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.doc_section_file()}
|
||||
</h2>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mb-4 flex items-center gap-3 rounded bg-brand-sand/20 px-3 py-2 text-sm text-gray-600"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
>{m.doc_current_file_label()}
|
||||
<strong class="font-medium text-brand-navy">{doc.originalFilename}</strong></span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- ── Section 3: Transkription ── -->
|
||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
||||
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">{m.form_label_transcription()}</h2>
|
||||
<textarea
|
||||
id="transcription"
|
||||
name="transcription"
|
||||
rows="12"
|
||||
placeholder={m.form_placeholder_transcription()}
|
||||
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm font-serif"
|
||||
>{doc.transcription || ''}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- ── Section 4: Datei ── -->
|
||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
||||
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">{m.doc_section_file()}</h2>
|
||||
|
||||
<div class="flex items-center gap-3 mb-4 text-sm text-gray-600 bg-brand-sand/20 rounded px-3 py-2">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg" alt="" aria-hidden="true" class="w-4 h-4 flex-shrink-0" />
|
||||
<span>{m.doc_current_file_label()} <strong class="text-brand-navy font-medium">{doc.originalFilename}</strong></span>
|
||||
</div>
|
||||
|
||||
<label for="file-upload" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{m.doc_file_replace_label()} <span class="font-normal text-gray-400">({m.doc_file_replace_note()})</span>
|
||||
</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
name="file"
|
||||
class="block w-full text-sm text-gray-500
|
||||
file:mr-4 file:py-2 file:px-4
|
||||
file:rounded file:border-0
|
||||
<label for="file-upload" class="mb-1 block text-sm font-medium text-gray-700">
|
||||
{m.doc_file_replace_label()}
|
||||
<span class="font-normal text-gray-400">({m.doc_file_replace_note()})</span>
|
||||
</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
name="file"
|
||||
class="block w-full cursor-pointer text-sm
|
||||
text-gray-500 file:mr-4 file:rounded
|
||||
file:border-0 file:bg-brand-sand/40
|
||||
file:px-4 file:py-2
|
||||
file:text-sm file:font-semibold
|
||||
file:bg-brand-sand/40 file:text-brand-navy
|
||||
hover:file:bg-brand-sand/60 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
file:text-brand-navy hover:file:bg-brand-sand/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Sticky Save Bar ── -->
|
||||
<div class="sticky bottom-0 z-10 bg-white border-t border-brand-sand shadow-[0_-2px_8px_rgba(0,0,0,0.06)] -mx-4 px-6 py-4 flex items-center justify-between">
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="text-sm text-gray-500 hover:text-brand-navy transition-colors font-medium"
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-6 py-2 bg-brand-navy text-white text-sm font-bold uppercase tracking-widest rounded hover:bg-brand-navy/80 transition-colors"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
<!-- ── Sticky Save Bar ── -->
|
||||
<div
|
||||
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-brand-sand bg-white px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
|
||||
>
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/80"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,58 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { parseBackendError, getErrorMessage } from '$lib/errors';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
const api = createApiClient(fetch);
|
||||
const personsResult = await api.GET('/api/persons');
|
||||
export async function load({
|
||||
fetch,
|
||||
locals,
|
||||
url
|
||||
}: {
|
||||
fetch: typeof globalThis.fetch;
|
||||
locals: App.Locals;
|
||||
url: URL;
|
||||
}) {
|
||||
const canWrite =
|
||||
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false;
|
||||
if (!canWrite) throw error(403, 'Forbidden');
|
||||
|
||||
if (!personsResult.response.ok) {
|
||||
return { persons: [] };
|
||||
const senderId = url.searchParams.get('senderId') || '';
|
||||
const receiverId = url.searchParams.get('receiverId') || '';
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
let initialSenderName = '';
|
||||
let initialReceivers: { id: string; firstName: string; lastName: string }[] = [];
|
||||
|
||||
const requests: Promise<void>[] = [];
|
||||
|
||||
if (senderId) {
|
||||
requests.push(
|
||||
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
|
||||
if (data) initialSenderName = `${data.firstName} ${data.lastName}`;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return { persons: personsResult.data };
|
||||
if (receiverId) {
|
||||
requests.push(
|
||||
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
|
||||
if (data)
|
||||
initialReceivers = [{ id: data.id!, firstName: data.firstName, lastName: data.lastName }];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const [personsResult] = await Promise.all([api.GET('/api/persons'), ...requests]);
|
||||
|
||||
return {
|
||||
persons: personsResult.response.ok ? personsResult.data : [],
|
||||
initialSenderId: senderId,
|
||||
initialSenderName,
|
||||
initialReceivers
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
@@ -3,13 +3,16 @@ import { enhance } from '$app/forms';
|
||||
import TagInput from '$lib/components/TagInput.svelte';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { form } = $props();
|
||||
let { data, form } = $props();
|
||||
|
||||
let tags: string[] = $state([]);
|
||||
let senderId = $state('');
|
||||
let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state([]);
|
||||
let senderId = $state(untrack(() => data.initialSenderId));
|
||||
let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state(
|
||||
untrack(() => data.initialReceivers)
|
||||
);
|
||||
|
||||
let dateDisplay = $state('');
|
||||
let dateIso = $state('');
|
||||
@@ -74,7 +77,9 @@ function handleDateInput(e: Event) {
|
||||
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
||||
<!-- ── Section 1: Wer & Wann ── -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">{m.doc_section_who_when()}</h2>
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.doc_section_who_when()}
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<!-- Datum -->
|
||||
@@ -104,7 +109,9 @@ function handleDateInput(e: Event) {
|
||||
|
||||
<!-- Ort -->
|
||||
<div>
|
||||
<label for="location" class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_location()}</label>
|
||||
<label for="location" class="mb-1 block text-sm font-medium text-gray-700"
|
||||
>{m.form_label_location()}</label
|
||||
>
|
||||
<input
|
||||
id="location"
|
||||
type="text"
|
||||
@@ -116,7 +123,12 @@ function handleDateInput(e: Event) {
|
||||
|
||||
<!-- Absender -->
|
||||
<div>
|
||||
<PersonTypeahead name="senderId" label={m.form_label_sender()} bind:value={senderId} />
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
label={m.form_label_sender()}
|
||||
bind:value={senderId}
|
||||
initialName={data.initialSenderName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empfänger -->
|
||||
@@ -129,12 +141,16 @@ function handleDateInput(e: Event) {
|
||||
|
||||
<!-- ── Section 2: Beschreibung ── -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">{m.doc_section_description()}</h2>
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.doc_section_description()}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Titel -->
|
||||
<div>
|
||||
<label for="title" class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_title()} *</label>
|
||||
<label for="title" class="mb-1 block text-sm font-medium text-gray-700"
|
||||
>{m.form_label_title()} *</label
|
||||
>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
@@ -168,7 +184,9 @@ function handleDateInput(e: Event) {
|
||||
|
||||
<!-- Inhalt -->
|
||||
<div>
|
||||
<label for="summary" class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_content()}</label>
|
||||
<label for="summary" class="mb-1 block text-sm font-medium text-gray-700"
|
||||
>{m.form_label_content()}</label
|
||||
>
|
||||
<textarea
|
||||
id="summary"
|
||||
name="summary"
|
||||
@@ -182,7 +200,9 @@ function handleDateInput(e: Event) {
|
||||
|
||||
<!-- ── Section 3: Transkription ── -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">{m.form_label_transcription()}</h2>
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.form_label_transcription()}
|
||||
</h2>
|
||||
<textarea
|
||||
id="transcription"
|
||||
name="transcription"
|
||||
@@ -194,10 +214,13 @@ function handleDateInput(e: Event) {
|
||||
|
||||
<!-- ── Section 4: Datei ── -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">{m.doc_section_file()}</h2>
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.doc_section_file()}
|
||||
</h2>
|
||||
|
||||
<label for="file-upload" class="mb-1 block text-sm font-medium text-gray-700">
|
||||
{m.doc_file_upload_label()} <span class="font-normal text-gray-400">({m.doc_file_upload_note()})</span>
|
||||
{m.doc_file_upload_label()}
|
||||
<span class="font-normal text-gray-400">({m.doc_file_upload_note()})</span>
|
||||
</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
|
||||
75
frontend/src/routes/documents/new/page.svelte.spec.ts
Normal file
75
frontend/src/routes/documents/new/page.svelte.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── Test data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
persons: [],
|
||||
initialSenderId: '',
|
||||
initialSenderName: '',
|
||||
initialReceivers: []
|
||||
};
|
||||
|
||||
// ─── Prefill – sender ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('New document page – sender prefill', () => {
|
||||
it('shows an empty sender input when no senderId is in the URL', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
||||
expect(input?.value).toBe('');
|
||||
});
|
||||
|
||||
it('shows the sender name in the typeahead input when initialSenderName is set', async () => {
|
||||
render(Page, {
|
||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
|
||||
form: null
|
||||
});
|
||||
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
||||
expect(input?.value).toBe('Hans Müller');
|
||||
});
|
||||
|
||||
it('sets the hidden senderId input to the prefilled ID', async () => {
|
||||
render(Page, {
|
||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
|
||||
form: null
|
||||
});
|
||||
const hidden = document.querySelector<HTMLInputElement>(
|
||||
'input[type="hidden"][name="senderId"]'
|
||||
);
|
||||
expect(hidden?.value).toBe('p1');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prefill – receiver ───────────────────────────────────────────────────────
|
||||
|
||||
describe('New document page – receiver prefill', () => {
|
||||
it('shows no receiver chips when initialReceivers is empty', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a receiver chip when initialReceivers has a person', async () => {
|
||||
const data = {
|
||||
...baseData,
|
||||
initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
|
||||
};
|
||||
render(Page, { data, form: null });
|
||||
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a hidden receiverIds input for the prefilled receiver', async () => {
|
||||
const data = {
|
||||
...baseData,
|
||||
initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
|
||||
};
|
||||
render(Page, { data, form: null });
|
||||
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');
|
||||
expect(hidden?.value).toBe('p2');
|
||||
});
|
||||
});
|
||||
@@ -1,39 +1,44 @@
|
||||
/* 1. Import Tailwind (replaces @tailwind base/components/utilities) */
|
||||
/* Fonts: Montserrat = Gotham substitute | Tinos = Times substitute (De Gruyter Brill CI) */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400;1,700&family=Montserrat:wght@400;500;600;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* 2. Define Custom Theme Variables — De Gruyter Brill CI */
|
||||
@theme {
|
||||
/* COLORS — exact De Gruyter Brill brand palette */
|
||||
--color-brand-navy: #012851; /* Prussian Blue */
|
||||
--color-brand-mint: #A1DCD8; /* Aqua Island */
|
||||
--color-brand-purple: #B4B9FF; /* Melrose */
|
||||
--color-brand-sand: #F0EFE9; /* Neutral paper tone */
|
||||
--color-brand-white: #ffffff;
|
||||
--color-brand-dark: #0D0D0D;
|
||||
/* COLORS — exact De Gruyter Brill brand palette */
|
||||
--color-brand-navy: #012851; /* Prussian Blue */
|
||||
--color-brand-mint: #a1dcd8; /* Aqua Island */
|
||||
--color-brand-purple: #b4b9ff; /* Melrose */
|
||||
--color-brand-sand: #f0efe9; /* Neutral paper tone */
|
||||
--color-brand-white: #ffffff;
|
||||
--color-brand-dark: #0d0d0d;
|
||||
|
||||
/* FONTS */
|
||||
--font-sans: "Montserrat", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: "Tinos", "Times New Roman", Georgia, serif;
|
||||
/* FONTS */
|
||||
--font-sans: 'Montserrat', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: 'Tinos', 'Times New Roman', Georgia, serif;
|
||||
|
||||
--text-huge: 4rem;
|
||||
--text-huge: 4rem;
|
||||
}
|
||||
|
||||
/* 3. Base Styles */
|
||||
@layer base {
|
||||
html {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
html {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
color: var(--color-brand-navy);
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
color: var(--color-brand-navy);
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
86
frontend/src/routes/layout.svelte.spec.ts
Normal file
86
frontend/src/routes/layout.svelte.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const emptySnippet = createRawSnippet(() => ({ render: () => '<span></span>' }));
|
||||
import Layout from './+layout.svelte';
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// Minimal data required by the layout
|
||||
const makeData = (overrides = {}) => ({
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'max',
|
||||
firstName: 'Max',
|
||||
lastName: 'Müller',
|
||||
groups: [],
|
||||
enabled: true,
|
||||
createdAt: ''
|
||||
},
|
||||
canWrite: true,
|
||||
...overrides
|
||||
});
|
||||
|
||||
// ─── User avatar ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Layout – user avatar button', () => {
|
||||
it('shows user initials when first and last name are set', async () => {
|
||||
render(Layout, { data: makeData(), children: emptySnippet });
|
||||
await expect.element(page.getByRole('button', { name: /MM/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fallback icon button when names are not set', async () => {
|
||||
render(Layout, {
|
||||
data: makeData({
|
||||
user: { id: '1', username: 'x', groups: [], enabled: true, createdAt: '' }
|
||||
}),
|
||||
children: emptySnippet
|
||||
});
|
||||
// Button should still exist (with aria-label for accessibility)
|
||||
await expect.element(page.getByRole('button', { name: /Profil/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dropdown ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Layout – user dropdown', () => {
|
||||
it('dropdown is hidden initially', async () => {
|
||||
render(Layout, { data: makeData(), children: emptySnippet });
|
||||
await tick();
|
||||
await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dropdown on button click', async () => {
|
||||
render(Layout, { data: makeData(), children: emptySnippet });
|
||||
await page.getByRole('button', { name: /MM/ }).click();
|
||||
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('profile link points to /profile', async () => {
|
||||
render(Layout, { data: makeData(), children: emptySnippet });
|
||||
await page.getByRole('button', { name: /MM/ }).click();
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: /Profil/i }))
|
||||
.toHaveAttribute('href', '/profile');
|
||||
});
|
||||
|
||||
it('logout button is in the dropdown', async () => {
|
||||
render(Layout, { data: makeData(), children: emptySnippet });
|
||||
await page.getByRole('button', { name: /MM/ }).click();
|
||||
await expect.element(page.getByRole('button', { name: /Abmelden/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes dropdown when Escape is pressed', async () => {
|
||||
render(Layout, { data: makeData(), children: emptySnippet });
|
||||
const btn = page.getByRole('button', { name: /MM/ });
|
||||
await btn.click();
|
||||
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await tick();
|
||||
await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -3,47 +3,47 @@ import { env } from '$env/dynamic/private';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
export const actions = {
|
||||
login: async ({ request, cookies, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const username = data.get('username') as string;
|
||||
const password = data.get('password') as string;
|
||||
login: async ({ request, cookies, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const username = data.get('username') as string;
|
||||
const password = data.get('password') as string;
|
||||
|
||||
if (!username || !password) {
|
||||
return fail(400, { error: 'Bitte Benutzername und Passwort eingeben.' });
|
||||
}
|
||||
if (!username || !password) {
|
||||
return fail(400, { error: 'Bitte Benutzername und Passwort eingeben.' });
|
||||
}
|
||||
|
||||
const credentials = btoa(`${username}:${password}`);
|
||||
const authHeader = `Basic ${credentials}`;
|
||||
const credentials = btoa(`${username}:${password}`);
|
||||
const authHeader = `Basic ${credentials}`;
|
||||
|
||||
// Raw fetch is intentional here: we need to pass an explicit Authorization
|
||||
// header built from the form data, not the cookie-based auth used elsewhere.
|
||||
try {
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const response = await fetch(`${baseUrl}/api/users/me`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: authHeader }
|
||||
});
|
||||
// Raw fetch is intentional here: we need to pass an explicit Authorization
|
||||
// header built from the form data, not the cookie-based auth used elsewhere.
|
||||
try {
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const response = await fetch(`${baseUrl}/api/users/me`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: authHeader }
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return fail(401, { error: getErrorMessage('UNAUTHORIZED') });
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return fail(401, { error: getErrorMessage('UNAUTHORIZED') });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
||||
}
|
||||
if (!response.ok) {
|
||||
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
||||
}
|
||||
|
||||
cookies.set('auth_token', authHeader, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: false, // set to true when HTTPS is available
|
||||
maxAge: 60 * 60 * 24
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
||||
}
|
||||
cookies.set('auth_token', authHeader, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: false, // set to true when HTTPS is available
|
||||
maxAge: 60 * 60 * 24
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
||||
}
|
||||
|
||||
return redirect(303, '/');
|
||||
}
|
||||
return redirect(303, '/');
|
||||
}
|
||||
} satisfies Actions;
|
||||
|
||||
@@ -1,75 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||
|
||||
let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
||||
let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
||||
|
||||
const locales = ['DE', 'EN', 'ES'] as const;
|
||||
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
|
||||
const activeLocale = $derived(getLocale().toUpperCase());
|
||||
const locales = ['DE', 'EN', 'ES'] as const;
|
||||
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
|
||||
const activeLocale = $derived(getLocale().toUpperCase());
|
||||
</script>
|
||||
|
||||
<div class="relative min-h-screen bg-white flex flex-col">
|
||||
<!-- DGB purple accent strip -->
|
||||
<div class="h-1 bg-brand-purple"></div>
|
||||
<div class="relative flex min-h-screen flex-col bg-white">
|
||||
<!-- DGB purple accent strip -->
|
||||
<div class="h-1 bg-brand-purple"></div>
|
||||
|
||||
<!-- Language switcher -->
|
||||
<div class="absolute top-4 right-4 flex items-center gap-1">
|
||||
{#each locales as locale (locale)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLocale(localeMap[locale])}
|
||||
class="text-xs font-sans tracking-widest px-1.5 py-1 transition-colors
|
||||
<!-- Language switcher -->
|
||||
<div class="absolute top-4 right-4 flex items-center gap-1">
|
||||
{#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>
|
||||
>
|
||||
{locale}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<!-- Logo -->
|
||||
<div class="mb-10 text-center">
|
||||
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans font-bold text-2xl tracking-widest text-brand-navy uppercase">Familienarchiv</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<!-- Logo -->
|
||||
<div class="mb-10 text-center">
|
||||
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans text-2xl font-bold tracking-widest text-brand-navy uppercase"
|
||||
>Familienarchiv</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="bg-white border border-brand-sand rounded-sm shadow-sm p-8">
|
||||
<h1 class="font-sans text-sm font-bold uppercase tracking-widest text-brand-navy mb-6">{m.login_heading()}</h1>
|
||||
<!-- Card -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-8 shadow-sm">
|
||||
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-brand-navy uppercase">
|
||||
{m.login_heading()}
|
||||
</h1>
|
||||
|
||||
<form method="POST" action="?/login" class="space-y-5">
|
||||
<div>
|
||||
<label for="username" class="block text-xs font-bold font-sans uppercase tracking-widest text-gray-500 mb-1.5">{m.login_label_username()}</label>
|
||||
<input type="text" name="username" id="username" required autocomplete="username"
|
||||
class="block w-full border border-gray-300 py-2.5 px-3 text-sm font-serif text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none" />
|
||||
</div>
|
||||
<form method="POST" action="?/login" class="space-y-5">
|
||||
<div>
|
||||
<label
|
||||
for="username"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||
>{m.login_label_username()}</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-xs font-bold font-sans uppercase tracking-widest text-gray-500 mb-1.5">{m.login_label_password()}</label>
|
||||
<input type="password" name="password" id="password" required autocomplete="current-password"
|
||||
class="block w-full border border-gray-300 py-2.5 px-3 text-sm font-serif text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||
>{m.login_label_password()}</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="text-red-600 text-xs font-sans font-medium text-center">{form.error}</div>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<div class="text-center font-sans text-xs font-medium text-red-600">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit"
|
||||
class="w-full bg-brand-navy text-white py-2.5 text-xs font-bold uppercase tracking-widest font-sans hover:bg-brand-navy/90 transition-colors mt-2">
|
||||
{m.login_btn_submit()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 w-full bg-brand-navy py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
||||
>
|
||||
{m.login_btn_submit()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="py-4 text-center">
|
||||
<p class="text-xs font-sans text-gray-300 uppercase tracking-widest">Familienarchiv</p>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="py-4 text-center">
|
||||
<p class="font-sans text-xs tracking-widest text-gray-300 uppercase">Familienarchiv</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import LoginPage from './+page.svelte';
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('Login page – rendering', () => {
|
||||
it('renders the page title', async () => {
|
||||
render(LoginPage, {});
|
||||
|
||||
@@ -2,11 +2,11 @@ import { redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ cookies }) => {
|
||||
// Das Auth-Cookie löschen
|
||||
cookies.delete('auth_token', { path: '/' });
|
||||
default: async ({ cookies }) => {
|
||||
// Das Auth-Cookie löschen
|
||||
cookies.delete('auth_token', { path: '/' });
|
||||
|
||||
// Zur Login-Seite werfen
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
// Zur Login-Seite werfen
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
} satisfies Actions;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
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';
|
||||
|
||||
@@ -13,10 +13,13 @@ vi.stubGlobal(
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
|
||||
);
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── Test data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const emptyData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
|
||||
documents: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
@@ -69,7 +72,9 @@ describe('Home page – search bar', () => {
|
||||
|
||||
it('pre-fills the search input from filters.q', async () => {
|
||||
render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } });
|
||||
await expect.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')).toHaveValue('Urlaub');
|
||||
await expect
|
||||
.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...'))
|
||||
.toHaveValue('Urlaub');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,15 +166,34 @@ describe('Home page – document list', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Keystroke preservation (issue #34) ──────────────────────────────────────
|
||||
|
||||
describe('Home page – search input keystroke preservation', () => {
|
||||
it('does not overwrite the search input while the user is focused and stale data arrives', async () => {
|
||||
const { rerender } = render(Page, { data: emptyData });
|
||||
|
||||
const input = page.getByPlaceholder('Suche in Titel, Inhalt, Ort...');
|
||||
|
||||
// User types "abc" — input is focused
|
||||
await input.click();
|
||||
await input.fill('abc');
|
||||
|
||||
// Simulate a navigation completing with stale data (q='a') while the user is still typing
|
||||
await rerender({ data: { ...emptyData, filters: { ...emptyData.filters, q: 'a' } } });
|
||||
await tick();
|
||||
|
||||
// Input must still show what the user typed, not the stale URL value
|
||||
await expect.element(input).toHaveValue('abc');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error state ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – error state', () => {
|
||||
it('shows the error message when data.error is set', async () => {
|
||||
const data = { ...emptyData, error: 'Daten konnten nicht geladen werden.' };
|
||||
render(Page, { data });
|
||||
await expect
|
||||
.element(page.getByText('Daten konnten nicht geladen werden.'))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Daten konnten nicht geladen werden.')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/home-error.png' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
const q = url.searchParams.get('q') || '';
|
||||
const api = createApiClient(fetch);
|
||||
const q = url.searchParams.get('q') || '';
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const { data } = await api.GET('/api/persons', {
|
||||
params: { query: { q: q || undefined } }
|
||||
});
|
||||
const result = await api.GET('/api/persons', {
|
||||
params: { query: { q: q || undefined } }
|
||||
});
|
||||
|
||||
return { persons: data ?? [], q };
|
||||
if (!result.response.ok) {
|
||||
throw error(result.response.status, getErrorMessage(undefined));
|
||||
}
|
||||
|
||||
return { persons: result.data!, q };
|
||||
}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let q = $state(untrack(() => data.q || ''));
|
||||
let qFocused = $state(false);
|
||||
|
||||
// Sync URL → local state after navigation, but not while the user is typing.
|
||||
$effect(() => {
|
||||
if (!qFocused) q = data.q || '';
|
||||
});
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
function handleSearch(e: Event) {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
function handleSearch() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
goto(`/persons?q=${value}`, { keepFocus: true });
|
||||
goto(`/persons?q=${q}`, { keepFocus: true });
|
||||
}, 300);
|
||||
}
|
||||
</script>
|
||||
@@ -25,13 +33,20 @@ function handleSearch(e: Event) {
|
||||
<p class="mt-2 max-w-xl font-sans text-sm text-brand-navy/60">
|
||||
{m.persons_subtitle()}
|
||||
</p>
|
||||
<a
|
||||
href="/persons/new"
|
||||
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg" alt="" aria-hidden="true" class="h-4 w-4" />
|
||||
{m.persons_btn_new()}
|
||||
</a>
|
||||
{#if data.canWrite}
|
||||
<a
|
||||
href="/persons/new"
|
||||
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.persons_btn_new()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Search Input -->
|
||||
@@ -42,14 +57,21 @@ function handleSearch(e: Event) {
|
||||
id="search"
|
||||
type="text"
|
||||
placeholder={m.persons_search_placeholder()}
|
||||
value={data.q || ''}
|
||||
bind:value={q}
|
||||
oninput={handleSearch}
|
||||
onfocus={() => (qFocused = true)}
|
||||
onblur={() => (qFocused = false)}
|
||||
class="block w-full rounded-sm border border-gray-300 bg-white py-2.5 pr-10 pl-4 font-sans text-sm text-brand-navy placeholder-gray-400 shadow-sm 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 text-gray-400"
|
||||
>
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg" alt="" aria-hidden="true" class="h-4 w-4 opacity-40" />
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 opacity-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,14 +84,19 @@ function handleSearch(e: Event) {
|
||||
<div
|
||||
class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-brand-sand/30 text-brand-navy"
|
||||
>
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg" alt="" aria-hidden="true" class="h-6 w-6" />
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
<p class="font-serif text-lg text-brand-navy">{m.persons_empty_heading()}</p>
|
||||
<p class="mt-1 font-sans text-sm text-gray-500">{m.persons_empty_text()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{#each data.persons as person}
|
||||
{#each data.persons as person (person.id)}
|
||||
<a href="/persons/{person.id}" class="group block h-full">
|
||||
<div
|
||||
class="relative flex h-full items-center gap-4 overflow-hidden rounded border border-brand-sand bg-white p-6 shadow-sm transition-all duration-200 hover:border-brand-navy hover:shadow-md"
|
||||
|
||||
@@ -3,67 +3,81 @@ import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
export async function load({ params, fetch }) {
|
||||
const { id } = params;
|
||||
const api = createApiClient(fetch);
|
||||
const { id } = params;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const [personResult, docsResult] = await Promise.all([
|
||||
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/documents', { params: { path: { id } } })
|
||||
]);
|
||||
const [personResult, sentDocsResult, receivedDocsResult] = await Promise.all([
|
||||
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } })
|
||||
]);
|
||||
|
||||
if (!personResult.response.ok) {
|
||||
const code = (personResult.error as unknown as { code?: string })?.code;
|
||||
throw error(personResult.response.status, getErrorMessage(code));
|
||||
}
|
||||
if (!personResult.response.ok) {
|
||||
const code = (personResult.error as unknown as { code?: string })?.code;
|
||||
throw error(personResult.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
return {
|
||||
person: personResult.data!,
|
||||
documents: docsResult.data ?? []
|
||||
};
|
||||
return {
|
||||
person: personResult.data!,
|
||||
sentDocuments: sentDocsResult.data ?? [],
|
||||
receivedDocuments: receivedDocsResult.data ?? []
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
update: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const firstName = formData.get('firstName')?.toString().trim();
|
||||
const lastName = formData.get('lastName')?.toString().trim();
|
||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||
update: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const firstName = formData.get('firstName')?.toString().trim();
|
||||
const lastName = formData.get('lastName')?.toString().trim();
|
||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||
const birthYearStr = formData.get('birthYear')?.toString().trim();
|
||||
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
||||
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
|
||||
}
|
||||
if (!firstName || !lastName) {
|
||||
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
|
||||
}
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const { error: apiError } = await api.PUT('/api/persons/{id}', {
|
||||
params: { path: { id: params.id } },
|
||||
body: { firstName, lastName, ...(alias ? { alias } : {}) }
|
||||
});
|
||||
const api = createApiClient(fetch);
|
||||
const { error: apiError } = await api.PUT('/api/persons/{id}', {
|
||||
params: { path: { id: params.id } },
|
||||
body: {
|
||||
firstName,
|
||||
lastName,
|
||||
...(alias ? { alias } : {}),
|
||||
...(notes ? { notes } : {}),
|
||||
...(birthYear ? { birthYear } : {}),
|
||||
...(deathYear ? { deathYear } : {})
|
||||
}
|
||||
});
|
||||
|
||||
if (apiError) {
|
||||
return fail(400, { updateError: 'Speichern fehlgeschlagen.' });
|
||||
}
|
||||
if (apiError) {
|
||||
return fail(400, { updateError: 'Speichern fehlgeschlagen.' });
|
||||
}
|
||||
|
||||
return { updated: true };
|
||||
},
|
||||
return { updated: true };
|
||||
},
|
||||
|
||||
merge: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const targetPersonId = formData.get('targetPersonId')?.toString();
|
||||
merge: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const targetPersonId = formData.get('targetPersonId')?.toString();
|
||||
|
||||
if (!targetPersonId) {
|
||||
return fail(400, { mergeError: 'Bitte eine Zielperson auswählen.' });
|
||||
}
|
||||
if (!targetPersonId) {
|
||||
return fail(400, { mergeError: 'Bitte eine Zielperson auswählen.' });
|
||||
}
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const { error: apiError } = await api.POST('/api/persons/{id}/merge', {
|
||||
params: { path: { id: params.id } },
|
||||
body: { targetPersonId }
|
||||
});
|
||||
const api = createApiClient(fetch);
|
||||
const { error: apiError } = await api.POST('/api/persons/{id}/merge', {
|
||||
params: { path: { id: params.id } },
|
||||
body: { targetPersonId }
|
||||
});
|
||||
|
||||
if (apiError) {
|
||||
return fail(400, { mergeError: 'Zusammenführen fehlgeschlagen.' });
|
||||
}
|
||||
if (apiError) {
|
||||
return fail(400, { mergeError: 'Zusammenführen fehlgeschlagen.' });
|
||||
}
|
||||
|
||||
throw redirect(303, `/persons/${targetPersonId}`);
|
||||
}
|
||||
throw redirect(303, `/persons/${targetPersonId}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,274 +1,611 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { sortDocumentsByDate, type SortDir } from '$lib/utils/sort';
|
||||
import { enhance } from '$app/forms';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { sortDocumentsByDate, type SortDir } from '$lib/utils/sort';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
let { data, form } = $props();
|
||||
let { data, form } = $props();
|
||||
|
||||
const person = $derived(data.person);
|
||||
const documents = $derived(data.documents);
|
||||
const person = $derived(data.person);
|
||||
const sentDocuments = $derived(data.sentDocuments);
|
||||
const receivedDocuments = $derived(data.receivedDocuments);
|
||||
|
||||
let sortDir = $state<SortDir>('DESC');
|
||||
const sortedDocuments = $derived(sortDocumentsByDate(documents, sortDir));
|
||||
const DOCS_PREVIEW_LIMIT = 5;
|
||||
|
||||
let editMode = $state(false);
|
||||
let mergeTargetId = $state('');
|
||||
let showMergeConfirm = $state(false);
|
||||
let sortDirSent = $state<SortDir>('DESC');
|
||||
let sortDirReceived = $state<SortDir>('DESC');
|
||||
let showAllSent = $state(false);
|
||||
let showAllReceived = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (form?.updated) editMode = false;
|
||||
});
|
||||
const sortedSentDocuments = $derived(sortDocumentsByDate(sentDocuments, sortDirSent));
|
||||
const sortedReceivedDocuments = $derived(sortDocumentsByDate(receivedDocuments, sortDirReceived));
|
||||
|
||||
$effect(() => {
|
||||
// Reset merge state whenever person changes
|
||||
person.id;
|
||||
mergeTargetId = '';
|
||||
showMergeConfirm = false;
|
||||
});
|
||||
const visibleSentDocuments = $derived(
|
||||
showAllSent ? sortedSentDocuments : sortedSentDocuments.slice(0, DOCS_PREVIEW_LIMIT)
|
||||
);
|
||||
const visibleReceivedDocuments = $derived(
|
||||
showAllReceived ? sortedReceivedDocuments : sortedReceivedDocuments.slice(0, DOCS_PREVIEW_LIMIT)
|
||||
);
|
||||
|
||||
function yearRange(docs: typeof sentDocuments) {
|
||||
const years = docs
|
||||
.filter((d) => d.documentDate)
|
||||
.map((d) => parseInt(d.documentDate!.substring(0, 4)));
|
||||
if (!years.length) return null;
|
||||
const min = Math.min(...years);
|
||||
const max = Math.max(...years);
|
||||
return min === max ? `${min}` : `${min} – ${max}`;
|
||||
}
|
||||
|
||||
const sentYearRange = $derived(yearRange(sentDocuments));
|
||||
const receivedYearRange = $derived(yearRange(receivedDocuments));
|
||||
|
||||
const coCorrespondents = $derived.by(() => {
|
||||
const freq = new SvelteMap<string, { id: string; name: string; count: number }>();
|
||||
|
||||
for (const doc of sentDocuments) {
|
||||
for (const receiver of doc.receivers ?? []) {
|
||||
const key = receiver.id;
|
||||
const existing = freq.get(key);
|
||||
if (existing) existing.count++;
|
||||
else
|
||||
freq.set(key, {
|
||||
id: receiver.id,
|
||||
name: `${receiver.firstName} ${receiver.lastName}`,
|
||||
count: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const doc of receivedDocuments) {
|
||||
if (doc.sender && doc.sender.id !== person.id) {
|
||||
const key = doc.sender.id;
|
||||
const existing = freq.get(key);
|
||||
if (existing) existing.count++;
|
||||
else
|
||||
freq.set(key, {
|
||||
id: doc.sender.id,
|
||||
name: `${doc.sender.firstName} ${doc.sender.lastName}`,
|
||||
count: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...freq.values()].sort((a, b) => b.count - a.count).slice(0, 5);
|
||||
});
|
||||
|
||||
let editMode = $state(false);
|
||||
let mergeTargetId = $state('');
|
||||
let showMergeConfirm = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (form?.updated) editMode = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Reset merge state whenever person changes
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
person.id; // reactive dependency
|
||||
mergeTargetId = '';
|
||||
showMergeConfirm = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="max-w-4xl mx-auto py-10 px-4">
|
||||
<div class="mx-auto max-w-4xl px-4 py-10">
|
||||
<!-- Back Link -->
|
||||
<div class="mb-6">
|
||||
<a
|
||||
href="/persons"
|
||||
class="group inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
/>
|
||||
{m.btn_back_to_overview()}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Back Link -->
|
||||
<div class="mb-6">
|
||||
<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">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg" alt="" aria-hidden="true" class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" />
|
||||
{m.btn_back_to_overview()}
|
||||
</a>
|
||||
</div>
|
||||
<!-- Header / Metadata Card -->
|
||||
<div class="mb-10 overflow-hidden rounded-sm border border-brand-sand bg-white shadow-sm">
|
||||
<div class="h-2 w-full bg-brand-navy"></div>
|
||||
|
||||
<!-- Header / Metadata Card -->
|
||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm overflow-hidden mb-10">
|
||||
<div class="h-2 bg-brand-navy w-full"></div>
|
||||
<div class="p-8 md:p-10">
|
||||
{#if editMode && data.canWrite}
|
||||
<!-- Edit Form -->
|
||||
<form method="POST" action="?/update" use:enhance>
|
||||
<div class="flex flex-col gap-6">
|
||||
<h2 class="border-b border-gray-100 pb-3 font-serif text-xl text-brand-navy">
|
||||
{m.person_edit_heading()}
|
||||
</h2>
|
||||
|
||||
<div class="p-8 md:p-10">
|
||||
{#if editMode}
|
||||
<!-- Edit Form -->
|
||||
<form method="POST" action="?/update" use:enhance>
|
||||
<div class="flex flex-col gap-6">
|
||||
<h2 class="text-xl font-serif text-brand-navy border-b border-gray-100 pb-3">{m.person_edit_heading()}</h2>
|
||||
{#if form?.updateError}
|
||||
<p class="rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{form.updateError}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if form?.updateError}
|
||||
<p class="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{form.updateError}</p>
|
||||
{/if}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
for="firstName"
|
||||
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>{m.form_label_first_name()} *</label
|
||||
>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
value={person.firstName}
|
||||
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="lastName"
|
||||
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>{m.form_label_last_name()} *</label
|
||||
>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
value={person.lastName}
|
||||
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label
|
||||
for="alias"
|
||||
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>{m.form_label_alias()}</label
|
||||
>
|
||||
<input
|
||||
id="alias"
|
||||
name="alias"
|
||||
type="text"
|
||||
value={person.alias ?? ''}
|
||||
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="birthYear"
|
||||
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>{m.person_label_birth_year()}</label
|
||||
>
|
||||
<input
|
||||
id="birthYear"
|
||||
name="birthYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
value={person.birthYear ?? ''}
|
||||
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="deathYear"
|
||||
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>{m.person_label_death_year()}</label
|
||||
>
|
||||
<input
|
||||
id="deathYear"
|
||||
name="deathYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
value={person.deathYear ?? ''}
|
||||
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label
|
||||
for="notes"
|
||||
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>{m.person_label_notes()}</label
|
||||
>
|
||||
<textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows="4"
|
||||
placeholder={m.person_placeholder_notes()}
|
||||
class="block w-full resize-y rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
|
||||
>{person.notes ?? ''}</textarea
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="firstName" class="block text-xs font-bold uppercase tracking-widest text-gray-400 mb-1">{m.form_label_first_name()} *</label>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
value={person.firstName}
|
||||
class="block w-full border border-gray-300 rounded px-3 py-2 font-serif text-brand-navy focus:outline-none focus:border-brand-navy"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="lastName" class="block text-xs font-bold uppercase tracking-widest text-gray-400 mb-1">{m.form_label_last_name()} *</label>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
value={person.lastName}
|
||||
class="block w-full border border-gray-300 rounded px-3 py-2 font-serif text-brand-navy focus:outline-none focus:border-brand-navy"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="alias" class="block text-xs font-bold uppercase tracking-widest text-gray-400 mb-1">{m.form_label_alias()}</label>
|
||||
<input
|
||||
id="alias"
|
||||
name="alias"
|
||||
type="text"
|
||||
value={person.alias ?? ''}
|
||||
class="block w-full border border-gray-300 rounded px-3 py-2 font-serif text-brand-navy focus:outline-none focus:border-brand-navy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-brand-navy px-5 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/80"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editMode = false)}
|
||||
class="rounded border border-gray-300 px-5 py-2 text-sm font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- View Mode -->
|
||||
<div class="flex flex-col items-start gap-8 md:flex-row">
|
||||
<div class="flex-shrink-0">
|
||||
<div
|
||||
class="flex h-24 w-24 items-center justify-center rounded-full border border-brand-sand bg-brand-sand/30 text-brand-navy"
|
||||
>
|
||||
<span class="font-serif text-3xl">{person.firstName[0]}{person.lastName[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="px-5 py-2 bg-brand-navy text-white text-sm font-bold uppercase tracking-widest rounded hover:bg-brand-navy/80 transition-colors">
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button type="button" onclick={() => (editMode = false)} class="px-5 py-2 border border-gray-300 text-gray-600 text-sm font-bold uppercase tracking-widest rounded hover:bg-gray-50 transition-colors">
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- View Mode -->
|
||||
<div class="flex flex-col md:flex-row gap-8 items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-24 h-24 rounded-full bg-brand-sand/30 flex items-center justify-center text-brand-navy border border-brand-sand">
|
||||
<span class="font-serif text-3xl">{person.firstName[0]}{person.lastName[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex-1">
|
||||
<div class="mb-8 flex items-start justify-between border-b border-gray-100 pb-4">
|
||||
<h1 class="font-serif text-4xl text-brand-navy">
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
</h1>
|
||||
<div class="ml-4 flex flex-shrink-0 items-center gap-2">
|
||||
{#if data.canWrite}
|
||||
<button
|
||||
onclick={() => (editMode = true)}
|
||||
class="inline-flex items-center gap-1.5 rounded border border-gray-300 px-3 py-1.5 text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:border-brand-navy hover:text-brand-navy"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 w-full">
|
||||
<div class="flex items-start justify-between mb-8 border-b border-gray-100 pb-4">
|
||||
<h1 class="text-4xl font-serif text-brand-navy">
|
||||
{person.firstName} {person.lastName}
|
||||
</h1>
|
||||
<div class="ml-4 flex-shrink-0 flex items-center gap-2">
|
||||
<a href="/conversations?senderId={person.id}" 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" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
||||
{m.person_btn_conversations()}
|
||||
</a>
|
||||
<button onclick={() => (editMode = true)} class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:border-brand-navy hover:text-brand-navy transition-colors">
|
||||
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg" alt="" aria-hidden="true" class="w-3.5 h-3.5" />
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
<div>
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>{m.person_label_full_name()}</span
|
||||
>
|
||||
<span class="block font-serif text-lg text-brand-navy"
|
||||
>{person.firstName} {person.lastName}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<span class="block text-xs font-bold font-sans text-gray-400 uppercase tracking-widest mb-1">{m.person_label_full_name()}</span>
|
||||
<span class="block text-lg font-serif text-brand-navy">{person.firstName} {person.lastName}</span>
|
||||
</div>
|
||||
{#if person.alias}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>{m.form_label_alias()}</span
|
||||
>
|
||||
<span class="block font-serif text-lg text-brand-navy italic"
|
||||
>"{person.alias}"</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if person.alias}
|
||||
<div>
|
||||
<span class="block text-xs font-bold font-sans text-gray-400 uppercase tracking-widest mb-1">{m.form_label_alias()}</span>
|
||||
<span class="block text-lg font-serif text-brand-navy italic">"{person.alias}"</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if person.birthYear || person.deathYear}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{#if person.birthYear && person.deathYear}{m.person_label_birth_year()} / {m.person_label_death_year()}{:else if person.birthYear}{m.person_label_birth_year()}{:else}{m.person_label_death_year()}{/if}
|
||||
</span>
|
||||
<span class="block font-serif text-lg text-brand-navy">
|
||||
{#if person.birthYear}* {person.birthYear}{/if}{#if person.birthYear && person.deathYear}
|
||||
{/if}{#if person.deathYear}† {person.deathYear}{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Merge Section -->
|
||||
{#key person.id}
|
||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm overflow-hidden mb-10">
|
||||
<div class="p-6 md:p-8">
|
||||
<h2 class="text-lg font-serif text-brand-navy mb-1">{m.person_merge_heading()}</h2>
|
||||
<p class="text-sm text-gray-500 font-sans mb-5">
|
||||
{m.person_merge_description()}
|
||||
</p>
|
||||
{#if person.notes}
|
||||
<div class="md:col-span-2">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>{m.person_label_notes()}</span
|
||||
>
|
||||
<p class="font-serif text-base whitespace-pre-wrap text-brand-navy">
|
||||
{person.notes}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if form?.mergeError}
|
||||
<p class="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2 mb-4">{form.mergeError}</p>
|
||||
{/if}
|
||||
<!-- Merge Section -->
|
||||
{#if data.canWrite}
|
||||
{#key person.id}
|
||||
<div class="mb-10 overflow-hidden rounded-sm border border-brand-sand bg-white shadow-sm">
|
||||
<div class="p-6 md:p-8">
|
||||
<h2 class="mb-1 font-serif text-lg text-brand-navy">{m.person_merge_heading()}</h2>
|
||||
<p class="mb-5 font-sans text-sm text-gray-500">
|
||||
{m.person_merge_description()}
|
||||
</p>
|
||||
|
||||
<form method="POST" action="?/merge" use:enhance>
|
||||
<input type="hidden" name="targetPersonId" bind:value={mergeTargetId} />
|
||||
{#if form?.mergeError}
|
||||
<p class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{form.mergeError}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 items-end">
|
||||
<div class="flex-1">
|
||||
<PersonTypeahead
|
||||
name="_targetPersonDisplay"
|
||||
label={m.person_merge_target_label()}
|
||||
value={mergeTargetId}
|
||||
onchange={(value) => { mergeTargetId = value; showMergeConfirm = false; }}
|
||||
/>
|
||||
</div>
|
||||
<form method="POST" action="?/merge" use:enhance>
|
||||
<input type="hidden" name="targetPersonId" bind:value={mergeTargetId} />
|
||||
|
||||
{#if !showMergeConfirm}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!mergeTargetId}
|
||||
onclick={() => (showMergeConfirm = true)}
|
||||
class="px-4 py-2 text-sm font-bold uppercase tracking-widest border border-red-300 text-red-600 rounded hover:bg-red-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{m.person_btn_merge()}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-sm font-bold uppercase tracking-widest bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
{m.person_btn_merge_confirm()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showMergeConfirm = false)}
|
||||
class="px-4 py-2 text-sm font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-3 sm:flex-row">
|
||||
<div class="flex-1">
|
||||
<PersonTypeahead
|
||||
name="_targetPersonDisplay"
|
||||
label={m.person_merge_target_label()}
|
||||
value={mergeTargetId}
|
||||
onchange={(value) => { mergeTargetId = value; showMergeConfirm = false; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if showMergeConfirm}
|
||||
<p class="mt-3 text-sm text-red-700 bg-red-50 border border-red-200 rounded px-3 py-2">
|
||||
{m.person_merge_warning()} <strong>{person.firstName} {person.lastName}</strong> {m.person_merge_will_be_deleted()}
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
{#if !showMergeConfirm}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!mergeTargetId}
|
||||
onclick={() => (showMergeConfirm = true)}
|
||||
class="rounded border border-red-300 px-4 py-2 text-sm font-bold tracking-widest text-red-600 uppercase transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{m.person_btn_merge()}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-red-600 px-4 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-red-700"
|
||||
>
|
||||
{m.person_btn_merge_confirm()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showMergeConfirm = false)}
|
||||
class="rounded border border-gray-300 px-4 py-2 text-sm font-bold tracking-widest text-gray-500 uppercase transition-colors hover:bg-gray-50"
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Linked Documents Section -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6 border-b border-brand-navy/10 pb-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-xl font-serif text-brand-navy">{m.person_docs_heading()}</h2>
|
||||
<span class="bg-brand-navy text-white text-xs font-bold px-2 py-1 rounded-full">
|
||||
{documents.length}
|
||||
</span>
|
||||
</div>
|
||||
{#if documents.length > 0}
|
||||
<button
|
||||
onclick={() => (sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC')}
|
||||
class="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-brand-navy transition-colors"
|
||||
aria-label={m.conv_sort_label()}
|
||||
>
|
||||
{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showMergeConfirm}
|
||||
<p
|
||||
class="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
>
|
||||
{m.person_merge_warning()} <strong>{person.firstName} {person.lastName}</strong>
|
||||
{m.person_merge_will_be_deleted()}
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
{#if documents.length === 0}
|
||||
<div class="p-12 text-center bg-white border border-brand-sand border-dashed rounded-sm">
|
||||
<p class="text-gray-500 font-sans">{m.person_no_docs()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-3">
|
||||
{#each sortedDocuments as doc}
|
||||
<li class="group">
|
||||
<a href="/documents/{doc.id}" class="block bg-white border border-brand-sand p-4 hover:border-brand-navy hover:shadow-md transition-all duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 overflow-hidden">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-brand-sand/20 text-brand-navy rounded flex items-center justify-center group-hover:bg-brand-mint group-hover:text-brand-navy transition-colors">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-serif text-base font-medium text-brand-navy truncate group-hover:underline decoration-brand-mint decoration-2 underline-offset-2">
|
||||
{doc.title || doc.originalFilename}
|
||||
</div>
|
||||
<div class="flex items-center text-xs font-sans text-gray-500 mt-0.5 space-x-2">
|
||||
<span>{doc.documentDate ? new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(doc.documentDate + 'T12:00:00')) : m.doc_no_date()}</span>
|
||||
{#if doc.location}
|
||||
<span class="text-brand-mint">•</span>
|
||||
<span>{doc.location}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center flex-shrink-0 pl-4">
|
||||
<span class="hidden sm:inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border
|
||||
<!-- Co-Correspondents Section -->
|
||||
{#if coCorrespondents.length > 0}
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-3 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.person_co_correspondents_heading()}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each coCorrespondents as c (c.id)}
|
||||
<a
|
||||
href="/conversations?senderId={person.id}&receiverId={c.id}"
|
||||
class="inline-flex items-center gap-1.5 rounded-full border border-brand-sand px-3 py-1 font-serif text-sm text-brand-navy transition-colors hover:border-brand-navy"
|
||||
>
|
||||
{c.name}
|
||||
<span class="font-sans text-xs text-gray-400">({c.count})</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sent Documents Section -->
|
||||
<div class="mb-10">
|
||||
<div class="mb-6 flex items-center gap-3 border-b border-brand-navy/10 pb-2">
|
||||
<h2 class="font-serif text-xl text-brand-navy">{m.person_docs_heading()}</h2>
|
||||
<span class="rounded-full bg-brand-navy px-2 py-1 text-xs font-bold text-white">
|
||||
{sentDocuments.length}
|
||||
</span>
|
||||
{#if sentYearRange}
|
||||
<span class="font-sans text-xs text-gray-400">{sentYearRange}</span>
|
||||
{/if}
|
||||
{#if sentDocuments.length > 1}
|
||||
<button
|
||||
onclick={() => (sortDirSent = sortDirSent === 'DESC' ? 'ASC' : 'DESC')}
|
||||
class="ml-auto text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
|
||||
>
|
||||
{sortDirSent === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if sentDocuments.length === 0}
|
||||
<div class="rounded-sm border border-dashed border-brand-sand bg-white p-12 text-center">
|
||||
<p class="font-sans text-gray-500">{m.person_no_docs()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-3">
|
||||
{#each visibleSentDocuments as doc (doc.id)}
|
||||
<li class="group">
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="block border border-brand-sand bg-white p-4 transition-all duration-200 hover:border-brand-navy hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 overflow-hidden">
|
||||
<div
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-brand-sand/20 text-brand-navy transition-colors group-hover:bg-brand-mint group-hover:text-brand-navy"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="truncate font-serif text-base font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center space-x-2 font-sans text-xs text-gray-500">
|
||||
<span
|
||||
>{doc.documentDate ? formatDate(doc.documentDate) : m.doc_no_date()}</span
|
||||
>
|
||||
{#if doc.location}
|
||||
<span class="text-brand-mint">•</span>
|
||||
<span>{doc.location}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 items-center gap-2 pl-4">
|
||||
<span
|
||||
class="hidden items-center rounded-full border px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase sm:inline-flex
|
||||
{doc.status === 'UPLOADED'
|
||||
? 'bg-brand-mint/20 text-brand-navy border-brand-mint/50'
|
||||
: 'bg-yellow-50 text-yellow-800 border-yellow-200'}">
|
||||
{doc.status}
|
||||
</span>
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg" alt="" aria-hidden="true" class="h-5 w-5 ml-4 opacity-40 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
? 'border-brand-mint/50 bg-brand-mint/20 text-brand-navy'
|
||||
: 'border-yellow-200 bg-yellow-50 text-yellow-800'}"
|
||||
>
|
||||
{doc.status}
|
||||
</span>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="ml-2 h-5 w-5 opacity-40 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if sentDocuments.length > DOCS_PREVIEW_LIMIT && !showAllSent}
|
||||
<button
|
||||
onclick={() => (showAllSent = true)}
|
||||
class="mt-3 text-xs font-bold tracking-widest text-brand-navy/50 uppercase transition-colors hover:text-brand-navy"
|
||||
>
|
||||
{m.person_show_more({ count: sentDocuments.length - DOCS_PREVIEW_LIMIT })}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Received Documents Section -->
|
||||
<div>
|
||||
<div class="mb-6 flex items-center gap-3 border-b border-brand-navy/10 pb-2">
|
||||
<h2 class="font-serif text-xl text-brand-navy">{m.person_received_docs_heading()}</h2>
|
||||
<span class="rounded-full bg-brand-navy px-2 py-1 text-xs font-bold text-white">
|
||||
{receivedDocuments.length}
|
||||
</span>
|
||||
{#if receivedYearRange}
|
||||
<span class="font-sans text-xs text-gray-400">{receivedYearRange}</span>
|
||||
{/if}
|
||||
{#if receivedDocuments.length > 1}
|
||||
<button
|
||||
onclick={() => (sortDirReceived = sortDirReceived === 'DESC' ? 'ASC' : 'DESC')}
|
||||
class="ml-auto text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
|
||||
>
|
||||
{sortDirReceived === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if receivedDocuments.length === 0}
|
||||
<div class="rounded-sm border border-dashed border-brand-sand bg-white p-12 text-center">
|
||||
<p class="font-sans text-gray-500">{m.person_no_received_docs()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-3">
|
||||
{#each visibleReceivedDocuments as doc (doc.id)}
|
||||
<li class="group">
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="block border border-brand-sand bg-white p-4 transition-all duration-200 hover:border-brand-navy hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 overflow-hidden">
|
||||
<div
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-brand-sand/20 text-brand-navy transition-colors group-hover:bg-brand-mint group-hover:text-brand-navy"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="truncate font-serif text-base font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center space-x-2 font-sans text-xs text-gray-500">
|
||||
<span
|
||||
>{doc.documentDate ? formatDate(doc.documentDate) : m.doc_no_date()}</span
|
||||
>
|
||||
{#if doc.location}
|
||||
<span class="text-brand-mint">•</span>
|
||||
<span>{doc.location}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 items-center gap-2 pl-4">
|
||||
<span
|
||||
class="hidden items-center rounded-full border px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase sm:inline-flex
|
||||
{doc.status === 'UPLOADED'
|
||||
? 'border-brand-mint/50 bg-brand-mint/20 text-brand-navy'
|
||||
: 'border-yellow-200 bg-yellow-50 text-yellow-800'}"
|
||||
>
|
||||
{doc.status}
|
||||
</span>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="ml-2 h-5 w-5 opacity-40 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if receivedDocuments.length > DOCS_PREVIEW_LIMIT && !showAllReceived}
|
||||
<button
|
||||
onclick={() => (showAllReceived = true)}
|
||||
class="mt-3 text-xs font-bold tracking-widest text-brand-navy/50 uppercase transition-colors hover:text-brand-navy"
|
||||
>
|
||||
{m.person_show_more({ count: receivedDocuments.length - DOCS_PREVIEW_LIMIT })}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
|
||||
export async function load({ locals }: { locals: App.Locals }) {
|
||||
const canWrite =
|
||||
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false;
|
||||
if (!canWrite) throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
|
||||
72
frontend/src/routes/persons/page.svelte.spec.ts
Normal file
72
frontend/src/routes/persons/page.svelte.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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';
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
const makePerson = (overrides = {}) => ({
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const emptyData = { user: undefined, canWrite: true, q: '', persons: [] };
|
||||
const dataWithPersons = { ...emptyData, persons: [makePerson()] };
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Persons page – rendering', () => {
|
||||
it('renders the search input', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills the search input from data.q', async () => {
|
||||
render(Page, { data: { ...emptyData, q: 'Müller' } });
|
||||
await expect.element(page.getByRole('textbox')).toHaveValue('Müller');
|
||||
});
|
||||
|
||||
it('shows empty state when no persons', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders person cards', async () => {
|
||||
render(Page, { data: dataWithPersons });
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links person card to detail page', async () => {
|
||||
render(Page, { data: dataWithPersons });
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: /Max Mustermann/ }))
|
||||
.toHaveAttribute('href', '/persons/1');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Keystroke preservation (issue #34) ──────────────────────────────────────
|
||||
|
||||
describe('Persons page – search input keystroke preservation', () => {
|
||||
it('does not overwrite the search input while the user is focused and stale data arrives', async () => {
|
||||
const { rerender } = render(Page, { data: emptyData });
|
||||
|
||||
const input = page.getByRole('textbox');
|
||||
|
||||
// User types "abc" — input is focused
|
||||
await input.click();
|
||||
await input.fill('abc');
|
||||
|
||||
// Simulate a navigation completing with stale data (q='a') while the user is still typing
|
||||
await rerender({ data: { ...emptyData, q: 'a' } });
|
||||
await tick();
|
||||
|
||||
// Input must still show what the user typed, not the stale URL value
|
||||
await expect.element(input).toHaveValue('abc');
|
||||
});
|
||||
});
|
||||
54
frontend/src/routes/profile/+page.server.ts
Normal file
54
frontend/src/routes/profile/+page.server.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return { user: locals.user };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
updateProfile: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const body = {
|
||||
firstName: formData.get('firstName')?.toString().trim() || undefined,
|
||||
lastName: formData.get('lastName')?.toString().trim() || undefined,
|
||||
birthDate: (formData.get('birthDate')?.toString() || undefined) as string | undefined,
|
||||
email: formData.get('email')?.toString().trim() || undefined,
|
||||
contact: formData.get('contact')?.toString().trim() || undefined
|
||||
};
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.PUT('/api/users/me', { body });
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { updateError: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
return { updateSuccess: true };
|
||||
},
|
||||
|
||||
changePassword: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const currentPassword = formData.get('currentPassword')?.toString() ?? '';
|
||||
const newPassword = formData.get('newPassword')?.toString() ?? '';
|
||||
const confirmPassword = formData.get('confirmPassword')?.toString() ?? '';
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
return fail(400, { passwordError: 'PASSWORDS_DO_NOT_MATCH' });
|
||||
}
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.POST('/api/users/me/password', {
|
||||
body: { currentPassword, newPassword }
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { passwordError: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
return { passwordSuccess: true };
|
||||
}
|
||||
};
|
||||
239
frontend/src/routes/profile/+page.svelte
Normal file
239
frontend/src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,239 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
function isoToGerman(iso: string | undefined): string {
|
||||
if (!iso) return '';
|
||||
const match = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) return '';
|
||||
return `${match[3]}.${match[2]}.${match[1]}`;
|
||||
}
|
||||
|
||||
function germanToIso(german: string): string {
|
||||
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) return '';
|
||||
return `${match[3]}-${match[2]}-${match[1]}`;
|
||||
}
|
||||
|
||||
let birthDateDisplay = $state(untrack(() => isoToGerman(data.user?.birthDate)));
|
||||
let birthDateIso = $state(untrack(() => data.user?.birthDate ?? ''));
|
||||
|
||||
function handleBirthDateInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const digits = input.value.replace(/\D/g, '').slice(0, 8);
|
||||
let formatted: string;
|
||||
if (digits.length <= 2) {
|
||||
formatted = digits;
|
||||
} else if (digits.length <= 4) {
|
||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
} else {
|
||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
}
|
||||
input.value = formatted;
|
||||
birthDateDisplay = formatted;
|
||||
birthDateIso = germanToIso(formatted);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<!-- Back link -->
|
||||
<a
|
||||
href="/"
|
||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{m.btn_back_to_overview()}
|
||||
</a>
|
||||
|
||||
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy">{m.profile_heading()}</h1>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Personal info card -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.profile_section_personal()}
|
||||
</h2>
|
||||
|
||||
{#if form?.updateSuccess}
|
||||
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
||||
{m.profile_saved()}
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.updateError}
|
||||
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
{form.updateError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" action="?/updateProfile" use:enhance>
|
||||
<div class="space-y-4">
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_first_name()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={data.user?.firstName ?? ''}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_last_name()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={data.user?.lastName ?? ''}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_birth_date()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="TT.MM.JJJJ"
|
||||
value={birthDateDisplay}
|
||||
oninput={handleBirthDateInput}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
<input type="hidden" name="birthDate" value={birthDateIso} />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_email()}
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={data.user?.email ?? ''}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_contact()}
|
||||
</span>
|
||||
<textarea
|
||||
name="contact"
|
||||
rows="3"
|
||||
placeholder={m.profile_contact_placeholder()}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
>{data.user?.contact ?? ''}</textarea
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-5 rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Password change card -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.profile_section_password()}
|
||||
</h2>
|
||||
|
||||
{#if form?.passwordSuccess}
|
||||
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
||||
{m.profile_password_changed()}
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.passwordError}
|
||||
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
{#if form.passwordError === 'PASSWORDS_DO_NOT_MATCH'}
|
||||
{m.profile_password_mismatch()}
|
||||
{:else}
|
||||
{form.passwordError}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" action="?/changePassword" use:enhance>
|
||||
<div class="space-y-4">
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_current_password()}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
name="currentPassword"
|
||||
required
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_new_password()}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
name="newPassword"
|
||||
required
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_new_password_confirm()}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
required
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-5 rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
16
frontend/src/routes/users/[id]/+page.server.ts
Normal file
16
frontend/src/routes/users/[id]/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/users/{id}', { params: { path: { id: params.id } } });
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
return { profileUser: result.data! };
|
||||
};
|
||||
103
frontend/src/routes/users/[id]/+page.svelte
Normal file
103
frontend/src/routes/users/[id]/+page.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const fullName = $derived.by(() => {
|
||||
const first = data.profileUser.firstName;
|
||||
const last = data.profileUser.lastName;
|
||||
return first || last ? [first, last].filter(Boolean).join(' ') : data.profileUser.username;
|
||||
});
|
||||
|
||||
const initials = $derived.by(() => {
|
||||
const first = data.profileUser.firstName;
|
||||
const last = data.profileUser.lastName;
|
||||
if (first && last) return `${first[0]}${last[0]}`.toUpperCase();
|
||||
if (first) return first[0].toUpperCase();
|
||||
if (last) return last[0].toUpperCase();
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<!-- Back link -->
|
||||
<a
|
||||
href="/"
|
||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurück
|
||||
</a>
|
||||
|
||||
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy">{m.user_profile_heading()}</h1>
|
||||
|
||||
<div class="max-w-md">
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<!-- Avatar -->
|
||||
<div class="mb-5 flex justify-center">
|
||||
{#if initials}
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full bg-brand-navy text-white"
|
||||
>
|
||||
<span class="font-serif text-xl font-bold">{initials}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full bg-brand-navy text-white"
|
||||
>
|
||||
<svg
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Name and username -->
|
||||
<div class="mb-5 text-center">
|
||||
<h2 class="font-serif text-xl font-bold text-brand-navy">{fullName}</h2>
|
||||
{#if data.profileUser.firstName || data.profileUser.lastName}
|
||||
<p class="mt-0.5 font-sans text-sm text-gray-400">@{data.profileUser.username}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Field rows -->
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if data.profileUser.email}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>E-Mail</span
|
||||
>
|
||||
<span class="font-serif text-sm text-brand-navy">{data.profileUser.email}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.profileUser.contact}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>Kontakt</span
|
||||
>
|
||||
<span class="font-serif text-sm text-brand-navy">{data.profileUser.contact}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,25 +1,25 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
navy: '#002850', // Header & Hero background
|
||||
mint: '#A6DAD8', // The Comma accent color
|
||||
sand: '#E4E2D7', // Content background
|
||||
white: '#ffffff',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
// Montserrat for UI/Headers, Merriweather for Body text (as established previously)
|
||||
sans: ['Montserrat', 'sans-serif'],
|
||||
serif: ['Merriweather', 'serif'],
|
||||
},
|
||||
fontSize: {
|
||||
'huge': '4rem', // For the large stats numbers (e.g., "29", "5000+")
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
navy: '#002850', // Header & Hero background
|
||||
mint: '#A6DAD8', // The Comma accent color
|
||||
sand: '#E4E2D7', // Content background
|
||||
white: '#ffffff'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
// Montserrat for UI/Headers, Merriweather for Body text (as established previously)
|
||||
sans: ['Montserrat', 'sans-serif'],
|
||||
serif: ['Merriweather', 'serif']
|
||||
},
|
||||
fontSize: {
|
||||
huge: '4rem' // For the large stats numbers (e.g., "29", "5000+")
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0', // Erlaubt Zugriff von außen
|
||||
port: 5173, // Standard SvelteKit Port
|
||||
port: 5173, // Standard SvelteKit Port
|
||||
// Proxy für API-Aufrufe während der Entwicklung (Browser -> Vite -> Spring Boot)
|
||||
proxy: {
|
||||
'/api': {
|
||||
@@ -37,8 +37,8 @@ export default defineConfig({
|
||||
enabled: true,
|
||||
provider: playwright(),
|
||||
instances: [{ browser: 'chromium', headless: true }],
|
||||
screenshotDirectory: 'test-results/screenshots',
|
||||
screenshotFailures: true
|
||||
screenshotDirectory: 'test-results/screenshots',
|
||||
screenshotFailures: true
|
||||
},
|
||||
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/lib/server/**']
|
||||
|
||||
Reference in New Issue
Block a user