Compare commits
56 Commits
13955a5459
...
refactor/i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca4861a90d | ||
|
|
ed2c0231db | ||
|
|
45490ebaac | ||
|
|
7fb6ec04ab | ||
|
|
8739511058 | ||
|
|
2b93ccf92d | ||
|
|
ff9ae198c4 | ||
|
|
8898863a48 | ||
|
|
eb8aa92cf0 | ||
|
|
bc3fec11a9 | ||
|
|
fe6c247882 | ||
|
|
accfa5373e | ||
|
|
34e7436fdc | ||
|
|
dbf7f0bc16 | ||
|
|
8be876492c | ||
|
|
76d6f234b4 | ||
|
|
655a2003cb | ||
|
|
c50845bcfc | ||
|
|
4446e80875 | ||
|
|
731cdc75ab | ||
|
|
4b8e0637ce | ||
|
|
793e632889 | ||
|
|
305f95a572 | ||
|
|
43595aeb8a | ||
|
|
947d8aeb6c | ||
|
|
7ec3e6170d | ||
|
|
7d456d8e8b | ||
|
|
24530cf85b | ||
|
|
57c44cf02f | ||
|
|
48223d5a3d | ||
|
|
04069c0286 | ||
|
|
3c46d820ad | ||
|
|
38d558182a | ||
|
|
25aa05411f | ||
|
|
f522ab633c | ||
|
|
593a6c8a38 | ||
|
|
67c03dab8c | ||
|
|
e302d3d689 | ||
|
|
a9aa1ec924 | ||
|
|
ce2bbf4230 | ||
|
|
69bcb3f8b2 | ||
|
|
34a97cbfa2 | ||
|
|
3d3d4b8616 | ||
|
|
e4719b9487 | ||
|
|
7562a400c0 | ||
|
|
2073a4b64a | ||
|
|
5c7efef307 | ||
|
|
74c9046745 | ||
|
|
81da127381 | ||
|
|
f206c0b9e9 | ||
|
|
15e532eb96 | ||
|
|
f241a71733 | ||
|
|
b83465020a | ||
|
|
f08897b801 | ||
|
|
a5979c4069 | ||
|
|
e8375d6c72 |
@@ -21,9 +21,10 @@ PORT_FRONTEND=5173
|
|||||||
PORT_MAILPIT_UI=8100
|
PORT_MAILPIT_UI=8100
|
||||||
PORT_MAILPIT_SMTP=1025
|
PORT_MAILPIT_SMTP=1025
|
||||||
|
|
||||||
# OCR Training — set a secret token to protect the /train and /segtrain endpoints on the
|
# OCR Training — secret token required to call /train and /segtrain on the OCR service.
|
||||||
# Python OCR microservice. Leave empty to disable token authentication (development only).
|
# Also set in the backend so it can pass the token through. Must not be empty in production.
|
||||||
# OCR_TRAINING_TOKEN=change-me-in-production
|
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
OCR_TRAINING_TOKEN=change-me-in-production
|
||||||
|
|
||||||
# Production SMTP — uncomment and fill in to send real emails instead of catching them
|
# Production SMTP — uncomment and fill in to send real emails instead of catching them
|
||||||
# APP_BASE_URL=https://your-domain.example.com
|
# APP_BASE_URL=https://your-domain.example.com
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ jobs:
|
|||||||
backend-unit-tests:
|
backend-unit-tests:
|
||||||
name: Backend Unit Tests
|
name: Backend Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
4
backend/.dockerignore
Normal file
4
backend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
target/
|
||||||
|
.git/
|
||||||
|
*.md
|
||||||
|
api_tests/
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
FROM eclipse-temurin:21-jdk
|
FROM eclipse-temurin:21.0.10_7-jdk-noble AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
EXPOSE 8080
|
# Copy wrapper and POM first — dependency layer is cached separately from source
|
||||||
|
COPY .mvn .mvn
|
||||||
|
COPY mvnw pom.xml ./
|
||||||
|
RUN --mount=type=cache,target=/root/.m2 ./mvnw dependency:go-offline -q
|
||||||
|
|
||||||
# Source code and mvnw are mounted via docker-compose volume at runtime.
|
COPY src ./src
|
||||||
# Maven dependencies are cached in a named volume (~/.m2).
|
# -Dmaven.test.skip=true skips test compilation entirely (not just execution)
|
||||||
CMD ["./mvnw", "spring-boot:run"]
|
RUN --mount=type=cache,target=/root/.m2 ./mvnw clean package -Dmaven.test.skip=true -q
|
||||||
|
|
||||||
|
FROM eclipse-temurin:21.0.10_7-jre-noble
|
||||||
|
WORKDIR /app
|
||||||
|
# Spring Boot repackages to *.jar; pre-repackage artifact uses .jar.original, not .jar
|
||||||
|
COPY --from=builder /app/target/*.jar app.jar
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["java", "-jar", "app.jar"]
|
||||||
|
|||||||
@@ -12,5 +12,5 @@ public interface OcrTrainingRunRepository extends JpaRepository<OcrTrainingRun,
|
|||||||
|
|
||||||
Optional<OcrTrainingRun> findFirstByStatus(TrainingStatus status);
|
Optional<OcrTrainingRun> findFirstByStatus(TrainingStatus status);
|
||||||
|
|
||||||
List<OcrTrainingRun> findTop5ByOrderByCreatedAtDesc();
|
List<OcrTrainingRun> findTop10ByOrderByCreatedAtDesc();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ public class OcrTrainingService {
|
|||||||
List<OcrTrainingRun> runs
|
List<OcrTrainingRun> runs
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private void assertNoRunningTraining() {
|
||||||
|
if (trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING).isPresent()) {
|
||||||
|
throw DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING,
|
||||||
|
"A training run is already in progress");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Not safe for horizontal scaling: training reloads the Kraken model in-process on the
|
// Not safe for horizontal scaling: training reloads the Kraken model in-process on the
|
||||||
// Python OCR service after each run. The DB-level RUNNING constraint (V30 partial unique
|
// Python OCR service after each run. The DB-level RUNNING constraint (V30 partial unique
|
||||||
// index) prevents concurrent training API calls, but cannot prevent two OCR service replicas
|
// index) prevents concurrent training API calls, but cannot prevent two OCR service replicas
|
||||||
@@ -53,10 +60,7 @@ public class OcrTrainingService {
|
|||||||
// Short transaction: guard check + create RUNNING row, then commit immediately.
|
// Short transaction: guard check + create RUNNING row, then commit immediately.
|
||||||
// The DB connection is released before the OCR HTTP call, which can take several minutes.
|
// The DB connection is released before the OCR HTTP call, which can take several minutes.
|
||||||
OcrTrainingRun run = Objects.requireNonNull(txTemplate.execute(status -> {
|
OcrTrainingRun run = Objects.requireNonNull(txTemplate.execute(status -> {
|
||||||
if (trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING).isPresent()) {
|
assertNoRunningTraining();
|
||||||
throw DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING,
|
|
||||||
"A training run is already in progress");
|
|
||||||
}
|
|
||||||
|
|
||||||
var eligibleBlocks = trainingDataExportService.queryEligibleBlocks();
|
var eligibleBlocks = trainingDataExportService.queryEligibleBlocks();
|
||||||
if (eligibleBlocks.size() < 5) {
|
if (eligibleBlocks.size() < 5) {
|
||||||
@@ -120,10 +124,7 @@ public class OcrTrainingService {
|
|||||||
public OcrTrainingRun triggerSegTraining(UUID triggeredBy) {
|
public OcrTrainingRun triggerSegTraining(UUID triggeredBy) {
|
||||||
// Same pattern as triggerTraining: narrow transactions around DB writes only.
|
// Same pattern as triggerTraining: narrow transactions around DB writes only.
|
||||||
OcrTrainingRun run = Objects.requireNonNull(txTemplate.execute(status -> {
|
OcrTrainingRun run = Objects.requireNonNull(txTemplate.execute(status -> {
|
||||||
if (trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING).isPresent()) {
|
assertNoRunningTraining();
|
||||||
throw DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING,
|
|
||||||
"A training run is already in progress");
|
|
||||||
}
|
|
||||||
|
|
||||||
var segBlocks = segmentationTrainingExportService.querySegmentationBlocks();
|
var segBlocks = segmentationTrainingExportService.querySegmentationBlocks();
|
||||||
if (segBlocks.size() < 5) {
|
if (segBlocks.size() < 5) {
|
||||||
@@ -162,11 +163,12 @@ public class OcrTrainingService {
|
|||||||
return Objects.requireNonNull(txTemplate.execute(status -> {
|
return Objects.requireNonNull(txTemplate.execute(status -> {
|
||||||
run.setStatus(TrainingStatus.DONE);
|
run.setStatus(TrainingStatus.DONE);
|
||||||
run.setCompletedAt(Instant.now());
|
run.setCompletedAt(Instant.now());
|
||||||
|
run.setCer(result.cer());
|
||||||
run.setLoss(result.loss());
|
run.setLoss(result.loss());
|
||||||
run.setAccuracy(result.accuracy());
|
run.setAccuracy(result.accuracy());
|
||||||
run.setEpochs(result.epochs());
|
run.setEpochs(result.epochs());
|
||||||
OcrTrainingRun updated = trainingRunRepository.save(run);
|
OcrTrainingRun updated = trainingRunRepository.save(run);
|
||||||
log.info("[trainingRun={}] Segmentation training completed — epochs={}", runId, result.epochs());
|
log.info("[trainingRun={}] Segmentation training completed — cer={} epochs={}", runId, result.cer(), result.epochs());
|
||||||
return updated;
|
return updated;
|
||||||
}));
|
}));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -193,7 +195,7 @@ public class OcrTrainingService {
|
|||||||
int totalOcrBlocks = (int) blockRepository.count();
|
int totalOcrBlocks = (int) blockRepository.count();
|
||||||
int availableSegBlocks = segmentationTrainingExportService.querySegmentationBlocks().size();
|
int availableSegBlocks = segmentationTrainingExportService.querySegmentationBlocks().size();
|
||||||
|
|
||||||
List<OcrTrainingRun> recentRuns = trainingRunRepository.findTop5ByOrderByCreatedAtDesc();
|
List<OcrTrainingRun> recentRuns = trainingRunRepository.findTop10ByOrderByCreatedAtDesc();
|
||||||
OcrTrainingRun lastRun = recentRuns.isEmpty() ? null : recentRuns.get(0);
|
OcrTrainingRun lastRun = recentRuns.isEmpty() ? null : recentRuns.get(0);
|
||||||
|
|
||||||
return new TrainingInfoResponse(
|
return new TrainingInfoResponse(
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class OcrTrainingServiceTest {
|
|||||||
service = new OcrTrainingService(runRepository, exportService, segExportService, ocrClient, healthClient, blockRepository, txTemplate);
|
service = new OcrTrainingService(runRepository, exportService, segExportService, ocrClient, healthClient, blockRepository, txTemplate);
|
||||||
|
|
||||||
when(blockRepository.count()).thenReturn(0L);
|
when(blockRepository.count()).thenReturn(0L);
|
||||||
when(runRepository.findTop5ByOrderByCreatedAtDesc()).thenReturn(List.of());
|
when(runRepository.findTop10ByOrderByCreatedAtDesc()).thenReturn(List.of());
|
||||||
when(segExportService.querySegmentationBlocks()).thenReturn(List.of());
|
when(segExportService.querySegmentationBlocks()).thenReturn(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +146,90 @@ class OcrTrainingServiceTest {
|
|||||||
run.getStatus() == TrainingStatus.FAILED && run.getErrorMessage() != null));
|
run.getStatus() == TrainingStatus.FAILED && run.getErrorMessage() != null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── triggerSegTraining ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void triggerSegTraining_throws409_whenRunningRunExists() {
|
||||||
|
when(runRepository.findFirstByStatus(TrainingStatus.RUNNING))
|
||||||
|
.thenReturn(Optional.of(OcrTrainingRun.builder()
|
||||||
|
.id(UUID.randomUUID()).status(TrainingStatus.RUNNING)
|
||||||
|
.blockCount(5).documentCount(2).modelName("blla").build()));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.triggerSegTraining(null))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("status")
|
||||||
|
.satisfies(s -> assertThat(s.toString()).contains("409"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void triggerSegTraining_throws422_whenFewerThan5Segments() {
|
||||||
|
when(runRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty());
|
||||||
|
when(segExportService.querySegmentationBlocks()).thenReturn(List.of(
|
||||||
|
TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(UUID.randomUUID()).build(),
|
||||||
|
TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(UUID.randomUUID()).build()
|
||||||
|
));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.triggerSegTraining(null))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void triggerSegTraining_createsRunWithBlla_andMarksDoneWithCer() throws Exception {
|
||||||
|
when(runRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
UUID docA = UUID.randomUUID();
|
||||||
|
UUID docB = UUID.randomUUID();
|
||||||
|
List<TranscriptionBlock> segs = List.of(
|
||||||
|
TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(),
|
||||||
|
TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(),
|
||||||
|
TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(),
|
||||||
|
TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(),
|
||||||
|
TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docB).build()
|
||||||
|
);
|
||||||
|
when(segExportService.querySegmentationBlocks()).thenReturn(segs);
|
||||||
|
when(segExportService.exportToZip()).thenReturn(out -> {});
|
||||||
|
when(ocrClient.segtrainModel(any())).thenReturn(new OcrClient.TrainingResult(null, 0.92, 0.08, 5));
|
||||||
|
|
||||||
|
OcrTrainingRun saved = OcrTrainingRun.builder()
|
||||||
|
.id(UUID.randomUUID()).status(TrainingStatus.RUNNING)
|
||||||
|
.blockCount(5).documentCount(2).modelName("blla").build();
|
||||||
|
when(runRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
service.triggerSegTraining(null);
|
||||||
|
|
||||||
|
verify(runRepository, atLeastOnce()).save(argThat(run ->
|
||||||
|
run.getStatus() == TrainingStatus.DONE
|
||||||
|
&& "blla".equals(run.getModelName())
|
||||||
|
&& run.getCer() != null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void triggerSegTraining_marksRunFailed_whenOcrClientThrows() throws Exception {
|
||||||
|
when(runRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
UUID docA = UUID.randomUUID();
|
||||||
|
List<TranscriptionBlock> segs = List.of(
|
||||||
|
TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(),
|
||||||
|
TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(),
|
||||||
|
TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(),
|
||||||
|
TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(),
|
||||||
|
TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build()
|
||||||
|
);
|
||||||
|
when(segExportService.querySegmentationBlocks()).thenReturn(segs);
|
||||||
|
when(segExportService.exportToZip()).thenReturn(out -> {});
|
||||||
|
when(ocrClient.segtrainModel(any())).thenThrow(new RuntimeException("seg timeout"));
|
||||||
|
|
||||||
|
OcrTrainingRun saved = OcrTrainingRun.builder()
|
||||||
|
.id(UUID.randomUUID()).status(TrainingStatus.RUNNING)
|
||||||
|
.blockCount(5).documentCount(1).modelName("blla").build();
|
||||||
|
when(runRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
service.triggerSegTraining(null);
|
||||||
|
|
||||||
|
verify(runRepository, atLeastOnce()).save(argThat(run ->
|
||||||
|
run.getStatus() == TrainingStatus.FAILED && run.getErrorMessage() != null));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Orphan recovery ──────────────────────────────────────────────────────
|
// ─── Orphan recovery ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -83,11 +83,11 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
mem_limit: 8g
|
mem_limit: 12g
|
||||||
memswap_limit: 8g
|
memswap_limit: 12g
|
||||||
volumes:
|
volumes:
|
||||||
- ocr_models:/app/models
|
- ocr_models:/app/models
|
||||||
- ocr_cache:/root/.cache
|
- ocr_cache:/root/.cache # Hugging Face / ketos model download cache — prevents re-downloads on container recreate
|
||||||
environment:
|
environment:
|
||||||
KRAKEN_MODEL_PATH: /app/models/german_kurrent.mlmodel
|
KRAKEN_MODEL_PATH: /app/models/german_kurrent.mlmodel
|
||||||
TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
|
TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
|
||||||
@@ -102,7 +102,7 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 12
|
||||||
start_period: 60s
|
start_period: 120s
|
||||||
|
|
||||||
# --- Backend: Spring Boot ---
|
# --- Backend: Spring Boot ---
|
||||||
backend:
|
backend:
|
||||||
@@ -112,9 +112,7 @@ services:
|
|||||||
container_name: archive-backend
|
container_name: archive-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
|
||||||
- ./import:/import
|
- ./import:/import
|
||||||
- maven_cache:/root/.m2
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -145,6 +143,7 @@ services:
|
|||||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${MAIL_SMTP_AUTH:-false}
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${MAIL_SMTP_AUTH:-false}
|
||||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
|
||||||
APP_OCR_BASE_URL: http://ocr-service:8000
|
APP_OCR_BASE_URL: http://ocr-service:8000
|
||||||
|
APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
|
||||||
ports:
|
ports:
|
||||||
- "${PORT_BACKEND}:8080"
|
- "${PORT_BACKEND}:8080"
|
||||||
networks:
|
networks:
|
||||||
@@ -154,7 +153,7 @@ services:
|
|||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
start_period: 60s
|
start_period: 30s # JAR starts in ~15s; was 60s when compilation happened at startup
|
||||||
|
|
||||||
# --- Frontend: SvelteKit (Dev Server) ---
|
# --- Frontend: SvelteKit (Dev Server) ---
|
||||||
frontend:
|
frontend:
|
||||||
@@ -190,6 +189,5 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
frontend_node_modules:
|
frontend_node_modules:
|
||||||
maven_cache:
|
|
||||||
ocr_models:
|
ocr_models:
|
||||||
ocr_cache:
|
ocr_cache:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ bun.lockb
|
|||||||
/src/lib/paraglide/
|
/src/lib/paraglide/
|
||||||
/src/lib/paraglide_bak*/
|
/src/lib/paraglide_bak*/
|
||||||
/src/paraglide/
|
/src/paraglide/
|
||||||
|
/project.inlang/
|
||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
/test-results/
|
/test-results/
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ test.describe('Authentication', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('protected routes redirect to /login without session', async ({ page }) => {
|
test('protected routes redirect to /login without session', async ({ page }) => {
|
||||||
for (const url of ['/documents/new', '/persons', '/conversations']) {
|
for (const url of ['/documents/new', '/persons', '/briefwechsel']) {
|
||||||
await page.goto(url);
|
await page.goto(url);
|
||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,132 +181,3 @@ test.describe('Person detail — sent and received documents', () => {
|
|||||||
// If no person has dated documents, the test is a no-op (year range is optional)
|
// If no person has dated documents, the test is a no-op (year range is optional)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Person detail — conversations link', () => {
|
|
||||||
test('co-correspondent chips link to conversations pre-filled with both persons', async ({
|
|
||||||
page
|
|
||||||
}) => {
|
|
||||||
await page.goto('/persons');
|
|
||||||
const firstLink = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
|
||||||
const href = await firstLink.getAttribute('href');
|
|
||||||
const personId = href!.split('/persons/')[1];
|
|
||||||
await firstLink.click();
|
|
||||||
await page.waitForSelector('[data-hydrated]');
|
|
||||||
|
|
||||||
// Co-correspondent chips link to /conversations?senderId=X&receiverId=Y
|
|
||||||
const chip = page.locator(`a[href^="/conversations?senderId=${personId}&receiverId="]`).first();
|
|
||||||
if ((await chip.count()) > 0) {
|
|
||||||
const chipHref = await chip.getAttribute('href');
|
|
||||||
expect(chipHref).toMatch(/\/conversations\?senderId=.+&receiverId=.+/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Conversations', () => {
|
|
||||||
test('shows the empty state when no persons are selected', async ({ page }) => {
|
|
||||||
await page.goto('/conversations');
|
|
||||||
await expect(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeVisible();
|
|
||||||
await page.screenshot({ path: 'test-results/e2e/conversations-empty.png' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('nav link is active on the conversations page', async ({ page }) => {
|
|
||||||
await page.goto('/conversations');
|
|
||||||
const navLink = page.getByRole('link', { name: 'Konversationen' });
|
|
||||||
await expect(navLink).toHaveClass(/bg-nav-active/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sort toggle changes the button label', async ({ page }) => {
|
|
||||||
await page.goto('/conversations');
|
|
||||||
await page.waitForSelector('[data-hydrated]');
|
|
||||||
const btn = page.getByRole('button', { name: /Sortierung/i });
|
|
||||||
await expect(btn).toContainText('Neueste zuerst');
|
|
||||||
await btn.click();
|
|
||||||
await expect(page).toHaveURL(/dir=ASC/);
|
|
||||||
await expect(btn).toContainText('Älteste zuerst');
|
|
||||||
await page.screenshot({ path: 'test-results/e2e/conversations-sort.png' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Conversations — enhancements', () => {
|
|
||||||
// Hans→Anna (1923) and Anna→Hans (1965) are seeded in DataInitializer
|
|
||||||
// Navigate directly by URL so the test doesn't rely on typeahead interaction
|
|
||||||
async function loadHansAnnaConversation(page: import('@playwright/test').Page) {
|
|
||||||
// Resolve person IDs from the persons list
|
|
||||||
await page.goto('/persons');
|
|
||||||
const hansLink = page.getByRole('link', { name: /Hans Müller/ }).first();
|
|
||||||
const hansHref = await hansLink.getAttribute('href');
|
|
||||||
const hansId = hansHref!.split('/').pop()!;
|
|
||||||
|
|
||||||
const annaLink = page.getByRole('link', { name: /Anna Schmidt/ }).first();
|
|
||||||
const annaHref = await annaLink.getAttribute('href');
|
|
||||||
const annaId = annaHref!.split('/').pop()!;
|
|
||||||
|
|
||||||
await page.goto(`/conversations?senderId=${hansId}&receiverId=${annaId}`);
|
|
||||||
await page.waitForURL(/senderId=/);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('shows document count and year range summary when both persons are selected', async ({
|
|
||||||
page
|
|
||||||
}) => {
|
|
||||||
await loadHansAnnaConversation(page);
|
|
||||||
// Hans→Anna (1923) + Anna→Hans (1965) = 2 documents, range 1923–1965
|
|
||||||
await expect(page.getByTestId('conv-summary')).toContainText('2');
|
|
||||||
await expect(page.getByTestId('conv-summary')).toContainText('1923');
|
|
||||||
await expect(page.getByTestId('conv-summary')).toContainText('1965');
|
|
||||||
await page.screenshot({ path: 'test-results/e2e/conversations-summary.png' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows year dividers between documents from different years', async ({ page }) => {
|
|
||||||
await loadHansAnnaConversation(page);
|
|
||||||
// Expect at least two year dividers (1923 and 1965)
|
|
||||||
await expect(page.getByTestId('year-divider').first()).toBeVisible();
|
|
||||||
const dividers = page.getByTestId('year-divider');
|
|
||||||
const texts = await dividers.allTextContents();
|
|
||||||
expect(texts.some((t) => t.includes('1923'))).toBe(true);
|
|
||||||
expect(texts.some((t) => t.includes('1965'))).toBe(true);
|
|
||||||
await page.screenshot({ path: 'test-results/e2e/conversations-year-dividers.png' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('swap button switches sender and receiver and reloads', async ({ page }) => {
|
|
||||||
await loadHansAnnaConversation(page);
|
|
||||||
const url = new URL(page.url());
|
|
||||||
const originalSenderId = url.searchParams.get('senderId')!;
|
|
||||||
const originalReceiverId = url.searchParams.get('receiverId')!;
|
|
||||||
|
|
||||||
await page.getByTestId('conv-swap-btn').click();
|
|
||||||
// Wait for the URL to reflect the swapped IDs (not just any URL with senderId=)
|
|
||||||
await page.waitForURL(
|
|
||||||
(url) => new URL(url).searchParams.get('senderId') === originalReceiverId
|
|
||||||
);
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -79,6 +79,8 @@
|
|||||||
"docs_list_from": "Von",
|
"docs_list_from": "Von",
|
||||||
"docs_list_to": "An",
|
"docs_list_to": "An",
|
||||||
"docs_list_unknown": "Unbekannt",
|
"docs_list_unknown": "Unbekannt",
|
||||||
|
"docs_group_undated": "Undatiert",
|
||||||
|
"docs_group_unknown": "Unbekannt",
|
||||||
"doc_section_who_when": "Wer & Wann",
|
"doc_section_who_when": "Wer & Wann",
|
||||||
"doc_section_description": "Beschreibung",
|
"doc_section_description": "Beschreibung",
|
||||||
"doc_section_file": "Datei",
|
"doc_section_file": "Datei",
|
||||||
@@ -134,8 +136,6 @@
|
|||||||
"person_co_correspondents_heading": "Häufige Korrespondenten",
|
"person_co_correspondents_heading": "Häufige Korrespondenten",
|
||||||
"person_correspondents_hint": "klicken für Konversation",
|
"person_correspondents_hint": "klicken für Konversation",
|
||||||
"person_show_more": "+ {count} weitere anzeigen",
|
"person_show_more": "+ {count} weitere anzeigen",
|
||||||
"conv_heading": "Briefwechsel",
|
|
||||||
"conv_subtitle": "Briefwechsel einer Person durchsuchen — mit oder ohne Korrespondent.",
|
|
||||||
"conv_label_person_a": "Person A (Absender)",
|
"conv_label_person_a": "Person A (Absender)",
|
||||||
"conv_label_person_b": "Korrespondent",
|
"conv_label_person_b": "Korrespondent",
|
||||||
"conv_label_from": "Zeitraum von",
|
"conv_label_from": "Zeitraum von",
|
||||||
@@ -144,30 +144,18 @@
|
|||||||
"conv_sort_newest": "Neueste zuerst",
|
"conv_sort_newest": "Neueste zuerst",
|
||||||
"conv_sort_oldest": "Älteste zuerst",
|
"conv_sort_oldest": "Älteste zuerst",
|
||||||
"conv_empty_heading": "Wessen Briefe möchten Sie lesen?",
|
"conv_empty_heading": "Wessen Briefe möchten Sie lesen?",
|
||||||
"conv_empty_text": "Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.",
|
|
||||||
"conv_hero_crosslink": "Suchen Sie ein bestimmtes Dokument? → Zur Dokumentensuche",
|
"conv_hero_crosslink": "Suchen Sie ein bestimmtes Dokument? → Zur Dokumentensuche",
|
||||||
"conv_no_results_heading": "Keine Dokumente gefunden.",
|
"conv_no_results_heading": "Keine Dokumente gefunden.",
|
||||||
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
|
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
|
||||||
"conv_swap_btn": "Personen tauschen",
|
"conv_swap_btn": "Personen tauschen",
|
||||||
"conv_summary": "{count} Dokumente · {yearFrom}–{yearTo}",
|
|
||||||
"conv_new_doc_link": "Neues Dokument in diesem Briefwechsel",
|
"conv_new_doc_link": "Neues Dokument in diesem Briefwechsel",
|
||||||
"conv_label_correspondent_optional": "Korrespondent",
|
|
||||||
"conv_hint_single_person": "Alle Briefe von {name} — wähle einen Korrespondenten oben um einzugrenzen",
|
|
||||||
"conv_hint_single_person_filtered": "Alle Briefe von {name} · {from}–{to} · {sortLabel}",
|
|
||||||
"conv_strip_period": "Zeitraum",
|
|
||||||
"conv_strip_from_placeholder": "Von…",
|
|
||||||
"conv_strip_to_placeholder": "Bis…",
|
|
||||||
"conv_strip_all_correspondents": "Alle Korrespondenten",
|
|
||||||
"conv_strip_sort_newest": "Neueste",
|
"conv_strip_sort_newest": "Neueste",
|
||||||
"conv_strip_sort_oldest": "Älteste",
|
"conv_strip_sort_oldest": "Älteste",
|
||||||
"conv_suggestions_heading": "Häufigste Korrespondenten",
|
"conv_suggestions_heading": "Häufigste Korrespondenten",
|
||||||
"conv_suggestions_all_label": "Alle Korrespondenten von {name}",
|
"conv_suggestions_all_label": "Alle Korrespondenten von {name}",
|
||||||
"conv_letters_count": "{count} Briefe",
|
"conv_letters_count": "{count} Briefe",
|
||||||
"conv_empty_search_placeholder": "Person suchen…",
|
|
||||||
"conv_hero_divider": "oder",
|
"conv_hero_divider": "oder",
|
||||||
"conv_empty_recent_label": "Zuletzt geöffnet",
|
"conv_empty_recent_label": "Zuletzt geöffnet",
|
||||||
"conv_asym_sent": "{count} von {name} →",
|
|
||||||
"conv_asym_received": "{count} von {name} ←",
|
|
||||||
"conv_no_party": "—",
|
"conv_no_party": "—",
|
||||||
"admin_heading": "Admin Dashboard",
|
"admin_heading": "Admin Dashboard",
|
||||||
"admin_tab_users": "Benutzer",
|
"admin_tab_users": "Benutzer",
|
||||||
@@ -333,6 +321,7 @@
|
|||||||
"comment_btn_post": "Senden",
|
"comment_btn_post": "Senden",
|
||||||
"comment_btn_reply": "Antworten",
|
"comment_btn_reply": "Antworten",
|
||||||
"comment_edited_label": "(Bearbeitet)",
|
"comment_edited_label": "(Bearbeitet)",
|
||||||
|
"comment_edit_hint": "Enter speichern · Esc abbrechen",
|
||||||
"comment_time_just_now": "gerade eben",
|
"comment_time_just_now": "gerade eben",
|
||||||
"comment_time_minutes": "vor {count} Minute(n)",
|
"comment_time_minutes": "vor {count} Minute(n)",
|
||||||
"comment_time_hours": "vor {count} Stunde(n)",
|
"comment_time_hours": "vor {count} Stunde(n)",
|
||||||
@@ -558,6 +547,7 @@
|
|||||||
"training_history_col_cer": "Fehlerrate",
|
"training_history_col_cer": "Fehlerrate",
|
||||||
"training_status_done": "Fertig",
|
"training_status_done": "Fertig",
|
||||||
"training_status_failed": "Fehler",
|
"training_status_failed": "Fehler",
|
||||||
|
"training_error_detail_label": "Fehlerdetails",
|
||||||
"training_status_running": "Läuft…",
|
"training_status_running": "Läuft…",
|
||||||
"training_seg_heading": "Segmentierung trainieren",
|
"training_seg_heading": "Segmentierung trainieren",
|
||||||
"training_seg_description": "Starte ein neues Training mit annotierten Segmentierungsbereichen, um die Texterkennung zu verbessern.",
|
"training_seg_description": "Starte ein neues Training mit annotierten Segmentierungsbereichen, um die Texterkennung zu verbessern.",
|
||||||
|
|||||||
@@ -79,6 +79,8 @@
|
|||||||
"docs_list_from": "From",
|
"docs_list_from": "From",
|
||||||
"docs_list_to": "To",
|
"docs_list_to": "To",
|
||||||
"docs_list_unknown": "Unknown",
|
"docs_list_unknown": "Unknown",
|
||||||
|
"docs_group_undated": "Undated",
|
||||||
|
"docs_group_unknown": "Unknown",
|
||||||
"doc_section_who_when": "Who & When",
|
"doc_section_who_when": "Who & When",
|
||||||
"doc_section_description": "Description",
|
"doc_section_description": "Description",
|
||||||
"doc_section_file": "File",
|
"doc_section_file": "File",
|
||||||
@@ -134,8 +136,6 @@
|
|||||||
"person_co_correspondents_heading": "Frequent correspondents",
|
"person_co_correspondents_heading": "Frequent correspondents",
|
||||||
"person_correspondents_hint": "click to view conversation",
|
"person_correspondents_hint": "click to view conversation",
|
||||||
"person_show_more": "+ {count} more",
|
"person_show_more": "+ {count} more",
|
||||||
"conv_heading": "Letters",
|
|
||||||
"conv_subtitle": "Browse a person's letters — with or without a correspondent.",
|
|
||||||
"conv_label_person_a": "Person A (Sender)",
|
"conv_label_person_a": "Person A (Sender)",
|
||||||
"conv_label_person_b": "Correspondent",
|
"conv_label_person_b": "Correspondent",
|
||||||
"conv_label_from": "Period from",
|
"conv_label_from": "Period from",
|
||||||
@@ -144,30 +144,18 @@
|
|||||||
"conv_sort_newest": "Newest first",
|
"conv_sort_newest": "Newest first",
|
||||||
"conv_sort_oldest": "Oldest first",
|
"conv_sort_oldest": "Oldest first",
|
||||||
"conv_empty_heading": "Whose letters would you like to read?",
|
"conv_empty_heading": "Whose letters would you like to read?",
|
||||||
"conv_empty_text": "Choose a person from the archive to see their letters — with or without a correspondent.",
|
|
||||||
"conv_hero_crosslink": "Looking for a specific document? → Go to document search",
|
"conv_hero_crosslink": "Looking for a specific document? → Go to document search",
|
||||||
"conv_no_results_heading": "No documents found.",
|
"conv_no_results_heading": "No documents found.",
|
||||||
"conv_no_results_text": "Try adjusting the time period.",
|
"conv_no_results_text": "Try adjusting the time period.",
|
||||||
"conv_swap_btn": "Swap persons",
|
"conv_swap_btn": "Swap persons",
|
||||||
"conv_summary": "{count} documents · {yearFrom}–{yearTo}",
|
|
||||||
"conv_new_doc_link": "New document in this exchange",
|
"conv_new_doc_link": "New document in this exchange",
|
||||||
"conv_label_correspondent_optional": "Correspondent",
|
|
||||||
"conv_hint_single_person": "All letters from {name} — choose a correspondent above to narrow down",
|
|
||||||
"conv_hint_single_person_filtered": "All letters from {name} · {from}–{to} · {sortLabel}",
|
|
||||||
"conv_strip_period": "Period",
|
|
||||||
"conv_strip_from_placeholder": "From…",
|
|
||||||
"conv_strip_to_placeholder": "To…",
|
|
||||||
"conv_strip_all_correspondents": "All correspondents",
|
|
||||||
"conv_strip_sort_newest": "Newest",
|
"conv_strip_sort_newest": "Newest",
|
||||||
"conv_strip_sort_oldest": "Oldest",
|
"conv_strip_sort_oldest": "Oldest",
|
||||||
"conv_suggestions_heading": "Top correspondents",
|
"conv_suggestions_heading": "Top correspondents",
|
||||||
"conv_suggestions_all_label": "All correspondents of {name}",
|
"conv_suggestions_all_label": "All correspondents of {name}",
|
||||||
"conv_letters_count": "{count} letters",
|
"conv_letters_count": "{count} letters",
|
||||||
"conv_empty_search_placeholder": "Search person…",
|
|
||||||
"conv_hero_divider": "or",
|
"conv_hero_divider": "or",
|
||||||
"conv_empty_recent_label": "Recently opened",
|
"conv_empty_recent_label": "Recently opened",
|
||||||
"conv_asym_sent": "{count} from {name} →",
|
|
||||||
"conv_asym_received": "{count} from {name} ←",
|
|
||||||
"conv_no_party": "—",
|
"conv_no_party": "—",
|
||||||
"admin_heading": "Admin Dashboard",
|
"admin_heading": "Admin Dashboard",
|
||||||
"admin_tab_users": "Users",
|
"admin_tab_users": "Users",
|
||||||
@@ -333,6 +321,7 @@
|
|||||||
"comment_btn_post": "Send",
|
"comment_btn_post": "Send",
|
||||||
"comment_btn_reply": "Reply",
|
"comment_btn_reply": "Reply",
|
||||||
"comment_edited_label": "(Edited)",
|
"comment_edited_label": "(Edited)",
|
||||||
|
"comment_edit_hint": "Enter to save · Esc to cancel",
|
||||||
"comment_time_just_now": "just now",
|
"comment_time_just_now": "just now",
|
||||||
"comment_time_minutes": "{count} minute(s) ago",
|
"comment_time_minutes": "{count} minute(s) ago",
|
||||||
"comment_time_hours": "{count} hour(s) ago",
|
"comment_time_hours": "{count} hour(s) ago",
|
||||||
@@ -558,6 +547,7 @@
|
|||||||
"training_history_col_cer": "Error Rate",
|
"training_history_col_cer": "Error Rate",
|
||||||
"training_status_done": "Done",
|
"training_status_done": "Done",
|
||||||
"training_status_failed": "Failed",
|
"training_status_failed": "Failed",
|
||||||
|
"training_error_detail_label": "Error details",
|
||||||
"training_status_running": "Running…",
|
"training_status_running": "Running…",
|
||||||
"training_seg_heading": "Train segmentation",
|
"training_seg_heading": "Train segmentation",
|
||||||
"training_seg_description": "Start a new training run using annotated segmentation regions to improve text detection.",
|
"training_seg_description": "Start a new training run using annotated segmentation regions to improve text detection.",
|
||||||
|
|||||||
@@ -79,6 +79,8 @@
|
|||||||
"docs_list_from": "De",
|
"docs_list_from": "De",
|
||||||
"docs_list_to": "Para",
|
"docs_list_to": "Para",
|
||||||
"docs_list_unknown": "Desconocido",
|
"docs_list_unknown": "Desconocido",
|
||||||
|
"docs_group_undated": "Sin fecha",
|
||||||
|
"docs_group_unknown": "Desconocido",
|
||||||
"doc_section_who_when": "Quién & Cuándo",
|
"doc_section_who_when": "Quién & Cuándo",
|
||||||
"doc_section_description": "Descripción",
|
"doc_section_description": "Descripción",
|
||||||
"doc_section_file": "Archivo",
|
"doc_section_file": "Archivo",
|
||||||
@@ -134,8 +136,6 @@
|
|||||||
"person_co_correspondents_heading": "Corresponsales frecuentes",
|
"person_co_correspondents_heading": "Corresponsales frecuentes",
|
||||||
"person_correspondents_hint": "clic para ver conversación",
|
"person_correspondents_hint": "clic para ver conversación",
|
||||||
"person_show_more": "+ {count} más",
|
"person_show_more": "+ {count} más",
|
||||||
"conv_heading": "Cartas",
|
|
||||||
"conv_subtitle": "Explore las cartas de una persona — con o sin corresponsal.",
|
|
||||||
"conv_label_person_a": "Persona A (Remitente)",
|
"conv_label_person_a": "Persona A (Remitente)",
|
||||||
"conv_label_person_b": "Corresponsal",
|
"conv_label_person_b": "Corresponsal",
|
||||||
"conv_label_from": "Período desde",
|
"conv_label_from": "Período desde",
|
||||||
@@ -144,30 +144,18 @@
|
|||||||
"conv_sort_newest": "Más reciente primero",
|
"conv_sort_newest": "Más reciente primero",
|
||||||
"conv_sort_oldest": "Más antiguo primero",
|
"conv_sort_oldest": "Más antiguo primero",
|
||||||
"conv_empty_heading": "¿De quién desea leer las cartas?",
|
"conv_empty_heading": "¿De quién desea leer las cartas?",
|
||||||
"conv_empty_text": "Elige una persona del archivo para ver sus cartas — con o sin corresponsal.",
|
|
||||||
"conv_hero_crosslink": "¿Busca un documento en particular? → Ir a la búsqueda",
|
"conv_hero_crosslink": "¿Busca un documento en particular? → Ir a la búsqueda",
|
||||||
"conv_no_results_heading": "No se encontraron documentos.",
|
"conv_no_results_heading": "No se encontraron documentos.",
|
||||||
"conv_no_results_text": "Intente ajustar el período de tiempo.",
|
"conv_no_results_text": "Intente ajustar el período de tiempo.",
|
||||||
"conv_swap_btn": "Intercambiar personas",
|
"conv_swap_btn": "Intercambiar personas",
|
||||||
"conv_summary": "{count} documentos · {yearFrom}–{yearTo}",
|
|
||||||
"conv_new_doc_link": "Nuevo documento en este intercambio",
|
"conv_new_doc_link": "Nuevo documento en este intercambio",
|
||||||
"conv_label_correspondent_optional": "Corresponsal",
|
|
||||||
"conv_hint_single_person": "Todas las cartas de {name} — elige un corresponsal arriba para filtrar",
|
|
||||||
"conv_hint_single_person_filtered": "Todas las cartas de {name} · {from}–{to} · {sortLabel}",
|
|
||||||
"conv_strip_period": "Período",
|
|
||||||
"conv_strip_from_placeholder": "Desde…",
|
|
||||||
"conv_strip_to_placeholder": "Hasta…",
|
|
||||||
"conv_strip_all_correspondents": "Todos los corresponsales",
|
|
||||||
"conv_strip_sort_newest": "Más reciente",
|
"conv_strip_sort_newest": "Más reciente",
|
||||||
"conv_strip_sort_oldest": "Más antiguo",
|
"conv_strip_sort_oldest": "Más antiguo",
|
||||||
"conv_suggestions_heading": "Corresponsales frecuentes",
|
"conv_suggestions_heading": "Corresponsales frecuentes",
|
||||||
"conv_suggestions_all_label": "Todos los corresponsales de {name}",
|
"conv_suggestions_all_label": "Todos los corresponsales de {name}",
|
||||||
"conv_letters_count": "{count} cartas",
|
"conv_letters_count": "{count} cartas",
|
||||||
"conv_empty_search_placeholder": "Buscar persona…",
|
|
||||||
"conv_hero_divider": "o",
|
"conv_hero_divider": "o",
|
||||||
"conv_empty_recent_label": "Recientemente abiertos",
|
"conv_empty_recent_label": "Recientemente abiertos",
|
||||||
"conv_asym_sent": "{count} de {name} →",
|
|
||||||
"conv_asym_received": "{count} de {name} ←",
|
|
||||||
"conv_no_party": "—",
|
"conv_no_party": "—",
|
||||||
"admin_heading": "Panel de administración",
|
"admin_heading": "Panel de administración",
|
||||||
"admin_tab_users": "Usuarios",
|
"admin_tab_users": "Usuarios",
|
||||||
@@ -333,6 +321,7 @@
|
|||||||
"comment_btn_post": "Enviar",
|
"comment_btn_post": "Enviar",
|
||||||
"comment_btn_reply": "Responder",
|
"comment_btn_reply": "Responder",
|
||||||
"comment_edited_label": "(Editado)",
|
"comment_edited_label": "(Editado)",
|
||||||
|
"comment_edit_hint": "Enter para guardar · Esc para cancelar",
|
||||||
"comment_time_just_now": "justo ahora",
|
"comment_time_just_now": "justo ahora",
|
||||||
"comment_time_minutes": "hace {count} minuto(s)",
|
"comment_time_minutes": "hace {count} minuto(s)",
|
||||||
"comment_time_hours": "hace {count} hora(s)",
|
"comment_time_hours": "hace {count} hora(s)",
|
||||||
@@ -558,6 +547,7 @@
|
|||||||
"training_history_col_cer": "Tasa de error",
|
"training_history_col_cer": "Tasa de error",
|
||||||
"training_status_done": "Listo",
|
"training_status_done": "Listo",
|
||||||
"training_status_failed": "Error",
|
"training_status_failed": "Error",
|
||||||
|
"training_error_detail_label": "Detalles del error",
|
||||||
"training_status_running": "Ejecutando…",
|
"training_status_running": "Ejecutando…",
|
||||||
"training_seg_heading": "Entrenar segmentación",
|
"training_seg_heading": "Entrenar segmentación",
|
||||||
"training_seg_description": "Inicia un nuevo entrenamiento con regiones de segmentación anotadas para mejorar la detección de texto.",
|
"training_seg_description": "Inicia un nuevo entrenamiento con regiones de segmentación anotadas para mejorar la detección de texto.",
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ describe('clickOutside action', () => {
|
|||||||
expect(fired).toBe(false);
|
expect(fired).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not dispatch clickoutside when event.defaultPrevented is true', () => {
|
||||||
|
const node = makeNode();
|
||||||
|
const outside = makeNode();
|
||||||
|
let fired = false;
|
||||||
|
node.addEventListener('clickoutside', () => (fired = true));
|
||||||
|
clickOutside(node);
|
||||||
|
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
event.preventDefault();
|
||||||
|
outside.dispatchEvent(event);
|
||||||
|
expect(fired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('removes the listener on destroy', () => {
|
it('removes the listener on destroy', () => {
|
||||||
const node = makeNode();
|
const node = makeNode();
|
||||||
const outside = makeNode();
|
const outside = makeNode();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export function clickOutside(node: HTMLElement): { destroy: () => void } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture phase (true) ensures this fires before any child stopPropagation() calls.
|
||||||
document.addEventListener('click', handleClick, true);
|
document.addEventListener('click', handleClick, true);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
111
frontend/src/lib/components/CommentMessage.svelte
Normal file
111
frontend/src/lib/components/CommentMessage.svelte
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import type { FlatMessage } from '$lib/types';
|
||||||
|
import { extractQuote } from '$lib/utils/comment';
|
||||||
|
import { getInitials } from '$lib/utils/personFormat';
|
||||||
|
import { relativeTime } from '$lib/utils/time';
|
||||||
|
import { renderBody } from '$lib/utils/mention';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
message: FlatMessage;
|
||||||
|
isOwn: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
editText: string;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onEditTextChange: (text: string) => void;
|
||||||
|
onEditKeydown: (e: KeyboardEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
message,
|
||||||
|
isOwn,
|
||||||
|
isEditing,
|
||||||
|
editText,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onEditTextChange,
|
||||||
|
onEditKeydown
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const wasEdited = $derived(message.updatedAt > message.createdAt);
|
||||||
|
const parsed = $derived(extractQuote(message.content));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div role="article" class="flex gap-2">
|
||||||
|
<!-- Avatar circle with initials -->
|
||||||
|
<div
|
||||||
|
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
|
||||||
|
>
|
||||||
|
{getInitials(message.authorName)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<!-- Author + timestamp -->
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="font-sans text-sm font-semibold text-ink">{message.authorName}</span>
|
||||||
|
{#if wasEdited}
|
||||||
|
<span class="font-sans text-xs text-ink-3"
|
||||||
|
>{relativeTime(message.updatedAt)} {m.comment_edited_label()}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="font-sans text-xs text-ink-3">{relativeTime(message.createdAt)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quote block (if present) -->
|
||||||
|
{#if parsed.quote}
|
||||||
|
<div class="my-1 border-l-2 border-line pl-2 font-serif text-base text-ink-3 italic">
|
||||||
|
“{parsed.quote}”
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit mode vs view mode -->
|
||||||
|
{#if isEditing}
|
||||||
|
<textarea
|
||||||
|
class="mt-1 w-full resize-none rounded border border-line bg-surface px-2 py-1 font-serif text-sm leading-relaxed text-ink outline-none focus:border-primary"
|
||||||
|
rows={2}
|
||||||
|
value={editText}
|
||||||
|
oninput={(e) => onEditTextChange((e.currentTarget as HTMLTextAreaElement).value)}
|
||||||
|
onkeydown={onEditKeydown}
|
||||||
|
></textarea>
|
||||||
|
<div class="mt-1 font-sans text-xs text-ink-3">{m.comment_edit_hint()}</div>
|
||||||
|
{:else}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="relative" onclick={() => { if (isOwn) onEdit(); }}>
|
||||||
|
<p
|
||||||
|
class="font-serif text-base leading-relaxed text-ink-2 {isOwn
|
||||||
|
? '-mx-1 cursor-text rounded px-1 transition-colors hover:bg-surface'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
|
||||||
|
{@html renderBody(parsed.body, message.mentionDTOs ?? [])}
|
||||||
|
</p>
|
||||||
|
{#if isOwn}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hover:text-error absolute -right-1 -bottom-1 cursor-pointer rounded p-2 text-ink-3 transition-colors"
|
||||||
|
aria-label="{m.btn_delete()} {message.authorName}"
|
||||||
|
onclick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
85
frontend/src/lib/components/CommentMessage.svelte.spec.ts
Normal file
85
frontend/src/lib/components/CommentMessage.svelte.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import CommentMessage from './CommentMessage.svelte';
|
||||||
|
import type { FlatMessage } from '$lib/types';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const baseMsg: FlatMessage = {
|
||||||
|
id: 'msg-1',
|
||||||
|
authorId: 'user-1',
|
||||||
|
authorName: 'Anna Müller',
|
||||||
|
content: 'Hello world',
|
||||||
|
createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||||
|
updatedAt: new Date(Date.now() - 5 * 60_000).toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
function defaultProps(overrides: Partial<Parameters<typeof render>[1]> = {}) {
|
||||||
|
return {
|
||||||
|
message: baseMsg,
|
||||||
|
isOwn: false,
|
||||||
|
isEditing: false,
|
||||||
|
editText: '',
|
||||||
|
onEdit: vi.fn(),
|
||||||
|
onDelete: vi.fn(),
|
||||||
|
onEditTextChange: vi.fn(),
|
||||||
|
onEditKeydown: vi.fn(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CommentMessage', () => {
|
||||||
|
it('renders author name', async () => {
|
||||||
|
render(CommentMessage, defaultProps());
|
||||||
|
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders initials in avatar', async () => {
|
||||||
|
render(CommentMessage, defaultProps());
|
||||||
|
await expect.element(page.getByText('AM')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders message body', async () => {
|
||||||
|
render(CommentMessage, defaultProps());
|
||||||
|
await expect.element(page.getByText('Hello world')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders quoted section when content contains a quote', async () => {
|
||||||
|
render(
|
||||||
|
CommentMessage,
|
||||||
|
defaultProps({
|
||||||
|
message: { ...baseMsg, content: '> "Interesting passage"\n\nMy reply' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await expect.element(page.getByText(/Interesting passage/)).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText('My reply')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete button for messages not owned by current user', async () => {
|
||||||
|
render(CommentMessage, defaultProps({ isOwn: false }));
|
||||||
|
await expect.element(page.getByRole('button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows delete button for own messages', async () => {
|
||||||
|
render(CommentMessage, defaultProps({ isOwn: true }));
|
||||||
|
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onDelete when delete button is clicked', async () => {
|
||||||
|
const onDelete = vi.fn();
|
||||||
|
render(CommentMessage, defaultProps({ isOwn: true, onDelete }));
|
||||||
|
await userEvent.click(page.getByRole('button'));
|
||||||
|
expect(onDelete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows edit textarea when isEditing is true', async () => {
|
||||||
|
render(
|
||||||
|
CommentMessage,
|
||||||
|
defaultProps({ isOwn: true, isEditing: true, editText: 'current edit text' })
|
||||||
|
);
|
||||||
|
const textarea = page.getByRole('textbox');
|
||||||
|
await expect.element(textarea).toBeInTheDocument();
|
||||||
|
await expect.element(textarea).toHaveValue('current edit text');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import type { Comment } from '$lib/types';
|
import type { Comment, FlatMessage } from '$lib/types';
|
||||||
import MentionEditor from '$lib/components/MentionEditor.svelte';
|
import MentionEditor from '$lib/components/MentionEditor.svelte';
|
||||||
import { renderBody, extractContent } from '$lib/utils/mention';
|
import CommentMessage from '$lib/components/CommentMessage.svelte';
|
||||||
import type { MentionDTO } from '$lib/types';
|
import { extractContent } from '$lib/utils/mention';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
annotationId?: string | null;
|
annotationId?: string | null;
|
||||||
@@ -32,16 +31,6 @@ let {
|
|||||||
onCountChange
|
onCountChange
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
type FlatMessage = {
|
|
||||||
id: string;
|
|
||||||
authorId: string | null;
|
|
||||||
authorName: string;
|
|
||||||
content: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
mentionDTOs?: MentionDTO[];
|
|
||||||
};
|
|
||||||
|
|
||||||
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
||||||
let newText: string = $state('');
|
let newText: string = $state('');
|
||||||
let posting: boolean = $state(false);
|
let posting: boolean = $state(false);
|
||||||
@@ -67,39 +56,10 @@ $effect(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function timeAgo(iso: string): string {
|
|
||||||
const diff = Date.now() - new Date(iso).getTime();
|
|
||||||
const minutes = Math.floor(diff / 60000);
|
|
||||||
if (minutes < 1) return m.comment_time_just_now();
|
|
||||||
if (minutes < 60) return m.comment_time_minutes({ count: minutes });
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return m.comment_time_hours({ count: hours });
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return m.comment_time_days({ count: days });
|
|
||||||
}
|
|
||||||
|
|
||||||
function wasEdited(c: { createdAt: string; updatedAt: string }): boolean {
|
|
||||||
return c.updatedAt > c.createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOwn(c: { authorId: string | null }): boolean {
|
function isOwn(c: { authorId: string | null }): boolean {
|
||||||
return currentUserId !== null && c.authorId === currentUserId;
|
return currentUserId !== null && c.authorId === currentUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
|
||||||
return name
|
|
||||||
.split(/\s+/)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((w) => w.charAt(0).toUpperCase())
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractQuote(content: string): { quote: string | null; body: string } {
|
|
||||||
const match = content.match(/^>\s*"(.+?)"\s*\n\n?([\s\S]*)$/);
|
|
||||||
if (match) return { quote: match[1], body: match[2] };
|
|
||||||
return { quote: null, body: content };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(commentsBase);
|
const res = await fetch(commentsBase);
|
||||||
@@ -221,77 +181,18 @@ onMount(() => {
|
|||||||
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
|
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div role="log" class="space-y-2">
|
||||||
{#each flatMessages as msg (msg.id)}
|
{#each flatMessages as msg (msg.id)}
|
||||||
{@const parsed = extractQuote(msg.content)}
|
<CommentMessage
|
||||||
<div class="flex gap-2">
|
message={msg}
|
||||||
<div
|
isOwn={isOwn(msg)}
|
||||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
|
isEditing={editingId === msg.id}
|
||||||
>
|
editText={editText}
|
||||||
{getInitials(msg.authorName)}
|
onEdit={() => startEdit(msg)}
|
||||||
</div>
|
onDelete={() => deleteComment(msg.id)}
|
||||||
<div class="min-w-0 flex-1">
|
onEditTextChange={(text) => { editText = text; }}
|
||||||
<div class="flex items-center gap-1.5">
|
onEditKeydown={(e) => handleEditKeydown(e, msg.id)}
|
||||||
<span class="font-sans text-sm font-semibold text-ink">{msg.authorName}</span>
|
/>
|
||||||
{#if wasEdited(msg)}
|
|
||||||
<span class="font-sans text-xs text-ink-3"
|
|
||||||
>{timeAgo(msg.updatedAt)} {m.comment_edited_label()}</span
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<span class="font-sans text-xs text-ink-3">{timeAgo(msg.createdAt)}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if parsed.quote}
|
|
||||||
<div class="my-1 border-l-2 border-line pl-2 font-serif text-base text-ink-3 italic">
|
|
||||||
“{parsed.quote}”
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if editingId === msg.id}
|
|
||||||
<textarea
|
|
||||||
class="mt-1 w-full resize-none rounded border border-line bg-surface px-2 py-1 font-serif text-sm leading-relaxed text-ink outline-none focus:border-primary"
|
|
||||||
rows={2}
|
|
||||||
bind:value={editText}
|
|
||||||
onkeydown={(e) => handleEditKeydown(e, msg.id)}
|
|
||||||
></textarea>
|
|
||||||
<div class="mt-1 font-sans text-xs text-ink-3">Enter speichern · Esc abbrechen</div>
|
|
||||||
{:else}
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div class="relative" onclick={() => { if (isOwn(msg)) startEdit(msg); }}>
|
|
||||||
<p
|
|
||||||
class="font-serif text-base leading-relaxed text-ink-2 {isOwn(msg) ? '-mx-1 cursor-text rounded px-1 transition-colors hover:bg-surface' : ''}"
|
|
||||||
>
|
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
|
|
||||||
{@html renderBody(parsed.body, msg.mentionDTOs ?? [])}
|
|
||||||
</p>
|
|
||||||
{#if isOwn(msg)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="hover:text-error absolute -right-1 -bottom-1 cursor-pointer rounded p-0.5 text-ink-3 transition-colors"
|
|
||||||
title={m.btn_delete()}
|
|
||||||
aria-label={m.btn_delete()}
|
|
||||||
onclick={(e) => { e.stopPropagation(); deleteComment(msg.id); }}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
||||||
import { getInitials as calcInitials, personAvatarColor } from '$lib/utils/personFormat';
|
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
|
||||||
|
|
||||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||||
type Tag = { id: string; name: string };
|
type Tag = { id: string; name: string };
|
||||||
@@ -32,10 +32,6 @@ let showAllReceivers = $state(false);
|
|||||||
|
|
||||||
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
|
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
|
||||||
|
|
||||||
function getInitials(person: Person): string {
|
|
||||||
return calcInitials(person);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFullName(person: Person): string {
|
function getFullName(person: Person): string {
|
||||||
return person.displayName;
|
return person.displayName;
|
||||||
}
|
}
|
||||||
@@ -51,7 +47,7 @@ function getFullName(person: Person): string {
|
|||||||
style="background-color: {personAvatarColor(person.id)}"
|
style="background-color: {personAvatarColor(person.id)}"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{getInitials(person)}
|
{getInitials(person.displayName)}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
|
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { formatDate } from '$lib/utils/personFormat';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
import PersonChipRow from './PersonChipRow.svelte';
|
import PersonChipRow from './PersonChipRow.svelte';
|
||||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||||
|
|||||||
15
frontend/src/lib/components/GroupDivider.svelte
Normal file
15
frontend/src/lib/components/GroupDivider.svelte
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { label }: { label: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-testid="group-divider"
|
||||||
|
role="separator"
|
||||||
|
aria-label={label}
|
||||||
|
class="relative flex items-center py-2 text-center"
|
||||||
|
>
|
||||||
|
<div class="flex-grow border-t border-line"></div>
|
||||||
|
<span class="mx-4 font-sans text-sm font-bold tracking-widest text-ink/60 uppercase">{label}</span
|
||||||
|
>
|
||||||
|
<div class="flex-grow border-t border-line"></div>
|
||||||
|
</div>
|
||||||
23
frontend/src/lib/components/GroupDivider.svelte.spec.ts
Normal file
23
frontend/src/lib/components/GroupDivider.svelte.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import GroupDivider from './GroupDivider.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('GroupDivider', () => {
|
||||||
|
it('renders the label text', async () => {
|
||||||
|
render(GroupDivider, { label: '1938' });
|
||||||
|
await expect.element(page.getByText('1938')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has data-testid="group-divider" on the root element', async () => {
|
||||||
|
render(GroupDivider, { label: 'Test' });
|
||||||
|
await expect.element(page.getByTestId('group-divider')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a person name label', async () => {
|
||||||
|
render(GroupDivider, { label: 'Anna Müller' });
|
||||||
|
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,53 +2,24 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import {
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
type NotificationItem,
|
import { createNotificationStream } from '$lib/hooks/useNotificationStream.svelte';
|
||||||
relativeTime,
|
import NotificationDropdown from './NotificationDropdown.svelte';
|
||||||
parseNotificationEvent
|
|
||||||
} from '$lib/utils/notifications';
|
|
||||||
|
|
||||||
let notifications: NotificationItem[] = $state([]);
|
|
||||||
let unreadCount: number = $state(0);
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
|
|
||||||
// DOM refs managed via attachments
|
|
||||||
let bellButtonEl: HTMLButtonElement | null = null;
|
let bellButtonEl: HTMLButtonElement | null = null;
|
||||||
let firstFocusableEl: HTMLButtonElement | null = null;
|
|
||||||
|
|
||||||
let eventSource: EventSource | null = null;
|
const stream = createNotificationStream();
|
||||||
|
|
||||||
async function fetchNotifications() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/notifications?size=10');
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
notifications = data.content ?? [];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch notifications', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchUnreadCount() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/notifications/unread-count');
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
unreadCount = data.count;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch unread count', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleDropdown() {
|
async function toggleDropdown() {
|
||||||
open = !open;
|
open = !open;
|
||||||
if (open) {
|
if (open) {
|
||||||
await fetchNotifications();
|
await stream.fetchNotifications();
|
||||||
// defer focus until DOM updates
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
firstFocusableEl?.focus();
|
const firstBtn = document.querySelector<HTMLButtonElement>(
|
||||||
|
'[role="dialog"] button, [role="dialog"] a'
|
||||||
|
);
|
||||||
|
firstBtn?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,16 +29,8 @@ function closeDropdown() {
|
|||||||
bellButtonEl?.focus();
|
bellButtonEl?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markRead(notification: NotificationItem) {
|
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
|
||||||
if (!notification.read) {
|
await stream.markRead(notification);
|
||||||
try {
|
|
||||||
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
|
|
||||||
notification.read = true;
|
|
||||||
unreadCount = Math.max(0, unreadCount - 1);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to mark notification as read', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const url = notification.annotationId
|
const url = notification.annotationId
|
||||||
? `/documents/${notification.documentId}?commentId=${notification.referenceId}&annotationId=${notification.annotationId}`
|
? `/documents/${notification.documentId}?commentId=${notification.referenceId}&annotationId=${notification.annotationId}`
|
||||||
: `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
|
: `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
|
||||||
@@ -75,18 +38,6 @@ async function markRead(notification: NotificationItem) {
|
|||||||
goto(url);
|
goto(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAllRead() {
|
|
||||||
try {
|
|
||||||
await fetch('/api/notifications/read-all', { method: 'POST' });
|
|
||||||
for (const n of notifications) {
|
|
||||||
n.read = true;
|
|
||||||
}
|
|
||||||
unreadCount = 0;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to mark all notifications as read', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape' && open) {
|
if (event.key === 'Escape' && open) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -94,7 +45,6 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attachment: stores the element reference for the bell button
|
|
||||||
function attachBellButton(node: HTMLButtonElement) {
|
function attachBellButton(node: HTMLButtonElement) {
|
||||||
bellButtonEl = node;
|
bellButtonEl = node;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -102,61 +52,30 @@ function attachBellButton(node: HTMLButtonElement) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attachment: stores the element reference for the first focusable element in the dropdown
|
|
||||||
function attachFirstFocusable(node: HTMLButtonElement) {
|
|
||||||
firstFocusableEl = node;
|
|
||||||
return () => {
|
|
||||||
firstFocusableEl = null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attachment: closes dropdown when clicking outside the wrapper element
|
|
||||||
function attachClickOutside(node: HTMLElement) {
|
|
||||||
const handleClick = (event: MouseEvent) => {
|
|
||||||
if (!node.contains(event.target as Node) && !event.defaultPrevented) {
|
|
||||||
if (open) {
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('click', handleClick, true);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('click', handleClick, true);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
fetchUnreadCount();
|
stream.init();
|
||||||
eventSource = new EventSource('/api/notifications/stream');
|
|
||||||
eventSource.addEventListener('notification', (e) => {
|
|
||||||
const notification = parseNotificationEvent(e.data);
|
|
||||||
if (!notification) return;
|
|
||||||
notifications = [notification, ...notifications];
|
|
||||||
if (!notification.read) unreadCount += 1;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
eventSource?.close();
|
stream.destroy();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<div class="relative" {@attach attachClickOutside}>
|
<div class="relative" use:clickOutside onclickoutside={() => { if (open) closeDropdown(); }}>
|
||||||
<!-- Bell button -->
|
<!-- Bell button -->
|
||||||
<button
|
<button
|
||||||
{@attach attachBellButton}
|
{@attach attachBellButton}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggleDropdown}
|
onclick={toggleDropdown}
|
||||||
aria-label={unreadCount > 0
|
aria-label={stream.unreadCount > 0
|
||||||
? m.notification_bell_unread_label({ count: unreadCount })
|
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
||||||
: m.notification_bell_label()}
|
: m.notification_bell_label()}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
<!-- Bell SVG -->
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-5 w-5"
|
class="h-5 w-5"
|
||||||
@@ -173,143 +92,22 @@ onDestroy(() => {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<!-- Unread badge -->
|
<!-- Persistent aria-live wrapper — always in DOM so live region history is preserved -->
|
||||||
{#if unreadCount > 0}
|
<span
|
||||||
<span
|
aria-live="polite"
|
||||||
aria-live="polite"
|
aria-atomic="true"
|
||||||
aria-atomic="true"
|
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-fg {stream.unreadCount > 0 ? '' : 'hidden'}"
|
||||||
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-fg"
|
>
|
||||||
>
|
{stream.unreadCount}
|
||||||
{unreadCount}
|
</span>
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Dropdown -->
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div
|
<NotificationDropdown
|
||||||
role="dialog"
|
notifications={stream.notifications}
|
||||||
aria-modal="true"
|
onMarkRead={handleMarkRead}
|
||||||
aria-label={m.notification_bell_label()}
|
onMarkAllRead={stream.markAllRead}
|
||||||
class="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
onClose={closeDropdown}
|
||||||
>
|
/>
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between border-b border-line px-4 py-3">
|
|
||||||
<span class="text-xs font-bold tracking-widest text-ink-2 uppercase">
|
|
||||||
{m.notification_bell_label()}
|
|
||||||
</span>
|
|
||||||
{#if notifications.length > 0}
|
|
||||||
<button
|
|
||||||
{@attach attachFirstFocusable}
|
|
||||||
type="button"
|
|
||||||
onclick={markAllRead}
|
|
||||||
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
|
||||||
>
|
|
||||||
{m.notification_mark_all_read()}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notification list -->
|
|
||||||
{#if notifications.length === 0}
|
|
||||||
<!-- Empty state -->
|
|
||||||
<div class="flex flex-col items-center gap-2 px-4 py-8 text-center text-sm text-ink-3">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-8 w-8 text-ink-3 opacity-40"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>{m.notification_empty()}</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<ul role="list">
|
|
||||||
{#each notifications as notification (notification.id)}
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => markRead(notification)}
|
|
||||||
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
|
|
||||||
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
|
||||||
>
|
|
||||||
<!-- Type icon -->
|
|
||||||
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
|
||||||
{#if notification.type === 'REPLY'}
|
|
||||||
<!-- Reply icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<!-- Mention icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Text + time -->
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-sm leading-snug text-ink">
|
|
||||||
{notification.type === 'REPLY'
|
|
||||||
? m.notification_type_reply({ actor: notification.actorName })
|
|
||||||
: m.notification_type_mention({ actor: notification.actorName })}
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Unread dot -->
|
|
||||||
{#if !notification.read}
|
|
||||||
<span
|
|
||||||
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
|
|
||||||
aria-label={m.notification_unread()}
|
|
||||||
></span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="border-t border-line px-4 py-2">
|
|
||||||
<a
|
|
||||||
href="/notifications"
|
|
||||||
onclick={closeDropdown}
|
|
||||||
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
|
||||||
>
|
|
||||||
{m.notification_view_all()}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
138
frontend/src/lib/components/NotificationDropdown.svelte
Normal file
138
frontend/src/lib/components/NotificationDropdown.svelte
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { relativeTime } from '$lib/utils/time';
|
||||||
|
import type { NotificationItem } from '$lib/hooks/useNotificationStream.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
notifications: NotificationItem[];
|
||||||
|
onMarkRead: (notification: NotificationItem) => void;
|
||||||
|
onMarkAllRead: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={m.notification_bell_label()}
|
||||||
|
class="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-line px-4 py-3">
|
||||||
|
<span class="text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||||
|
{m.notification_bell_label()}
|
||||||
|
</span>
|
||||||
|
{#if notifications.length > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onMarkAllRead}
|
||||||
|
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.notification_mark_all_read()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification list -->
|
||||||
|
{#if notifications.length === 0}
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div class="flex flex-col items-center gap-2 px-4 py-8 text-center text-sm text-ink-3">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 text-ink-3 opacity-40"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{m.notification_empty()}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul role="list" class="max-h-[24rem] overflow-y-auto">
|
||||||
|
{#each notifications as notification (notification.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onMarkRead(notification)}
|
||||||
|
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
|
||||||
|
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||||
|
>
|
||||||
|
<!-- Type icon -->
|
||||||
|
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
||||||
|
{#if notification.type === 'REPLY'}
|
||||||
|
<!-- Reply icon -->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- Mention icon -->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Text + time -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm leading-snug text-ink">
|
||||||
|
{notification.type === 'REPLY'
|
||||||
|
? m.notification_type_reply({ actor: notification.actorName })
|
||||||
|
: m.notification_type_mention({ actor: notification.actorName })}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unread dot -->
|
||||||
|
{#if !notification.read}
|
||||||
|
<span
|
||||||
|
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
|
||||||
|
aria-label={m.notification_unread()}
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="border-t border-line px-4 py-2">
|
||||||
|
<a
|
||||||
|
href="/notifications"
|
||||||
|
onclick={onClose}
|
||||||
|
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.notification_view_all()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
125
frontend/src/lib/components/PdfControls.svelte
Normal file
125
frontend/src/lib/components/PdfControls.svelte
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
isLoaded: boolean;
|
||||||
|
showAnnotations: boolean;
|
||||||
|
annotationCount: number;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onZoomIn: () => void;
|
||||||
|
onZoomOut: () => void;
|
||||||
|
onToggleAnnotations: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
isLoaded,
|
||||||
|
showAnnotations,
|
||||||
|
annotationCount,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
onZoomIn,
|
||||||
|
onZoomOut,
|
||||||
|
onToggleAnnotations
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 items-center justify-between gap-2 border-b border-pdf-ctrl px-4 py-2">
|
||||||
|
<!-- Page navigation: prev button, page counter, next button -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={onPrev}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
aria-label="Zurück"
|
||||||
|
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if totalPages > 0}
|
||||||
|
<span class="font-sans text-xs text-ink-2 tabular-nums">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={onNext}
|
||||||
|
disabled={!isLoaded || currentPage >= totalPages}
|
||||||
|
aria-label="Weiter"
|
||||||
|
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zoom controls -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onclick={onZoomOut}
|
||||||
|
aria-label="Verkleinern"
|
||||||
|
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path stroke-linecap="round" d="M21 21l-4.35-4.35M8 11h6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={onZoomIn}
|
||||||
|
aria-label="Vergrößern"
|
||||||
|
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path stroke-linecap="round" d="M21 21l-4.35-4.35M11 8v6M8 11h6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Annotation visibility toggle (only when annotations exist) -->
|
||||||
|
{#if annotationCount > 0}
|
||||||
|
<button
|
||||||
|
onclick={onToggleAnnotations}
|
||||||
|
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||||
|
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
||||||
|
? 'text-ink-2 hover:bg-surface/10'
|
||||||
|
: 'bg-surface/10 text-accent'}"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5 shrink-0"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
{#if showAnnotations}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
{showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, setContext } from 'svelte';
|
import { onMount, setContext } from 'svelte';
|
||||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
import { createPdfRenderer } from '$lib/hooks/usePdfRenderer.svelte';
|
||||||
|
import PdfControls from './PdfControls.svelte';
|
||||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||||
import type { Annotation } from '$lib/types';
|
import type { Annotation } from '$lib/types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
@@ -34,26 +35,12 @@ let {
|
|||||||
flashAnnotationId?: string | null;
|
flashAnnotationId?: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
const renderer = createPdfRenderer();
|
||||||
let currentPage = $state(1);
|
|
||||||
let totalPages = $state(0);
|
|
||||||
let scale = $state(1.5);
|
|
||||||
let loading = $state(false);
|
|
||||||
let error = $state<string | null>(null);
|
|
||||||
|
|
||||||
// Canvas and text layer container refs — bound via bind:this, not reactive state
|
// Canvas and text layer container refs — bound via bind:this
|
||||||
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
||||||
let textLayerEl = $state<HTMLDivElement | null>(null);
|
let textLayerEl = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Internal mutable refs for in-flight tasks — NOT $state to avoid reactive loops
|
|
||||||
let renderTask: RenderTask | null = null;
|
|
||||||
let textLayerInstance: { cancel: () => void } | null = null;
|
|
||||||
|
|
||||||
// Holds the dynamically-loaded pdfjs module (browser-only)
|
|
||||||
// Not $state — we use pdfjsReady as the reactive trigger instead
|
|
||||||
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
|
||||||
let pdfjsReady = $state(false);
|
|
||||||
|
|
||||||
let annotations = $state<Annotation[]>([]);
|
let annotations = $state<Annotation[]>([]);
|
||||||
let showAnnotations = $state(true);
|
let showAnnotations = $state(true);
|
||||||
let annotationUpdateError = $state<string | null>(null);
|
let annotationUpdateError = $state<string | null>(null);
|
||||||
@@ -66,115 +53,62 @@ const visibleAnnotations = $derived(
|
|||||||
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
|
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
await renderer.init();
|
||||||
const [lib, { default: workerUrl }] = await Promise.all([
|
|
||||||
import('pdfjs-dist'),
|
|
||||||
import('pdfjs-dist/build/pdf.worker.min.mjs?url')
|
|
||||||
]);
|
|
||||||
lib.GlobalWorkerOptions.workerSrc = workerUrl;
|
|
||||||
pdfjsLib = lib;
|
|
||||||
pdfjsReady = true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadDocument(src: string) {
|
$effect(() => {
|
||||||
if (!pdfjsLib) return;
|
if (renderer.pdfjsReady && url) {
|
||||||
loading = true;
|
renderer.loadDocument(url);
|
||||||
error = null;
|
|
||||||
pdfDoc = null;
|
|
||||||
currentPage = 1;
|
|
||||||
totalPages = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const loadingTask = pdfjsLib.getDocument(src);
|
|
||||||
const doc = await loadingTask.promise;
|
|
||||||
pdfDoc = doc;
|
|
||||||
totalPages = doc.numPages;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to load PDF';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
async function renderPage(doc: PDFDocumentProxy, pageNum: number) {
|
// Wire DOM elements to the renderer and trigger rendering.
|
||||||
if (!pdfjsLib || !canvasEl || !textLayerEl) return;
|
// canvasEl is read synchronously so Svelte tracks it as a dependency:
|
||||||
|
// when the canvas reappears after the loading spinner (loading → false),
|
||||||
// Cancel any in-flight render
|
// this effect re-fires and renders the already-loaded PDF.
|
||||||
if (renderTask) {
|
$effect(() => {
|
||||||
renderTask.cancel();
|
if (!canvasEl || !textLayerEl) return;
|
||||||
renderTask = null;
|
renderer.setElements(canvasEl, textLayerEl);
|
||||||
}
|
// Also track currentPage and scale so page-nav / zoom re-renders work.
|
||||||
if (textLayerInstance) {
|
if (renderer.isLoaded && renderer.currentPage && renderer.scale > 0) {
|
||||||
textLayerInstance.cancel();
|
renderer.renderCurrentPage().then(() => renderer.prerender());
|
||||||
textLayerInstance = null;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let page: PDFPageProxy;
|
$effect(() => {
|
||||||
try {
|
if (documentId && annotationReloadKey >= 0) {
|
||||||
page = await doc.getPage(pageNum);
|
loadAnnotations(documentId);
|
||||||
} catch {
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (transcribeMode) showAnnotations = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll-sync: when activeAnnotationId changes, navigate to its page
|
||||||
|
let prevActiveAnnotationId: string | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
const id = activeAnnotationId;
|
||||||
|
if (!id || id === prevActiveAnnotationId || !renderer.isLoaded) {
|
||||||
|
prevActiveAnnotationId = id;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
prevActiveAnnotationId = id;
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const ann = annotations.find((a) => a.id === id);
|
||||||
const viewport = page.getViewport({ scale: scale * dpr });
|
if (!ann) return;
|
||||||
|
|
||||||
const canvas = canvasEl;
|
if (ann.pageNumber !== renderer.currentPage) {
|
||||||
const ctx = canvas.getContext('2d');
|
renderer.goToPage(ann.pageNumber);
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
canvas.width = viewport.width;
|
|
||||||
canvas.height = viewport.height;
|
|
||||||
canvas.style.width = `${viewport.width / dpr}px`;
|
|
||||||
canvas.style.height = `${viewport.height / dpr}px`;
|
|
||||||
|
|
||||||
const task = page.render({ canvas, canvasContext: ctx, viewport });
|
|
||||||
renderTask = task;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await task.promise;
|
|
||||||
} catch (e: unknown) {
|
|
||||||
if (
|
|
||||||
typeof e === 'object' &&
|
|
||||||
e !== null &&
|
|
||||||
'name' in e &&
|
|
||||||
(e as { name: string }).name === 'RenderingCancelledException'
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
renderTask = null;
|
|
||||||
|
|
||||||
// Text layer
|
requestAnimationFrame(() => {
|
||||||
const textDiv = textLayerEl;
|
requestAnimationFrame(() => {
|
||||||
if (!textDiv) return;
|
const el = document.querySelector(`[data-testid="annotation-${id}"]`);
|
||||||
textDiv.innerHTML = '';
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
textDiv.style.width = `${viewport.width / dpr}px`;
|
});
|
||||||
textDiv.style.height = `${viewport.height / dpr}px`;
|
|
||||||
|
|
||||||
const tl = new pdfjsLib.TextLayer({
|
|
||||||
textContentSource: page.streamTextContent(),
|
|
||||||
container: textDiv,
|
|
||||||
viewport
|
|
||||||
});
|
});
|
||||||
textLayerInstance = tl;
|
});
|
||||||
try {
|
|
||||||
await tl.render();
|
|
||||||
} catch {
|
|
||||||
// cancelled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prerender(doc: PDFDocumentProxy, pageNum: number) {
|
|
||||||
const neighbors = [pageNum - 1, pageNum + 1].filter((n) => n >= 1 && n <= doc.numPages);
|
|
||||||
for (const n of neighbors) {
|
|
||||||
try {
|
|
||||||
await doc.getPage(n);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAnnotations(docId: string) {
|
async function loadAnnotations(docId: string) {
|
||||||
if (!docId) return;
|
if (!docId) return;
|
||||||
@@ -213,7 +147,7 @@ setContext('annotationUpdate', updateAnnotation);
|
|||||||
|
|
||||||
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
|
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
|
||||||
if (!documentId || !transcribeMode) return;
|
if (!documentId || !transcribeMode) return;
|
||||||
await onTranscriptionDraw?.({ ...rect, pageNumber: currentPage });
|
await onTranscriptionDraw?.({ ...rect, pageNumber: renderer.currentPage });
|
||||||
await loadAnnotations(documentId);
|
await loadAnnotations(documentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,82 +155,13 @@ function handleAnnotationClick(id: string) {
|
|||||||
activeAnnotationId = id;
|
activeAnnotationId = id;
|
||||||
onAnnotationClick?.(id);
|
onAnnotationClick?.(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (pdfjsReady && url) {
|
|
||||||
loadDocument(url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
// Read scale synchronously so Svelte tracks it as a dependency.
|
|
||||||
// Without this, zoom changes don't re-trigger the effect because
|
|
||||||
// scale is only read inside the async renderPage call.
|
|
||||||
if (pdfDoc && currentPage && scale > 0) {
|
|
||||||
renderPage(pdfDoc, currentPage).then(() => {
|
|
||||||
if (pdfDoc) prerender(pdfDoc, currentPage);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (documentId && annotationReloadKey >= 0) {
|
|
||||||
loadAnnotations(documentId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (transcribeMode) showAnnotations = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scroll-sync: when activeAnnotationId changes, navigate to its page
|
|
||||||
let prevActiveAnnotationId: string | null = null;
|
|
||||||
$effect(() => {
|
|
||||||
const id = activeAnnotationId;
|
|
||||||
if (!id || id === prevActiveAnnotationId || !pdfDoc) {
|
|
||||||
prevActiveAnnotationId = id;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
prevActiveAnnotationId = id;
|
|
||||||
|
|
||||||
const ann = annotations.find((a) => a.id === id);
|
|
||||||
if (!ann) return;
|
|
||||||
|
|
||||||
if (ann.pageNumber !== currentPage) {
|
|
||||||
currentPage = ann.pageNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
// After page renders, scroll the annotation into view (double-rAF for async render)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const el = document.querySelector(`[data-testid="annotation-${id}"]`);
|
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function prevPage() {
|
|
||||||
if (currentPage > 1) currentPage -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextPage() {
|
|
||||||
if (pdfDoc && currentPage < pdfDoc.numPages) currentPage += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function zoomIn() {
|
|
||||||
scale += 0.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
function zoomOut() {
|
|
||||||
if (scale > 0.5) scale -= 0.25;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !url}
|
{#if !url}
|
||||||
<div class="flex h-full w-full items-center justify-center bg-pdf-bg text-ink-3">
|
<div class="flex h-full w-full items-center justify-center bg-pdf-bg text-ink-3">
|
||||||
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if renderer.error}
|
||||||
<div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3">
|
<div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3">
|
||||||
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
||||||
<a
|
<a
|
||||||
@@ -351,136 +216,23 @@ function zoomOut() {
|
|||||||
<span class="font-sans text-xs text-red-300">{annotationUpdateError}</span>
|
<span class="font-sans text-xs text-red-300">{annotationUpdateError}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Controls -->
|
|
||||||
<div
|
|
||||||
class="flex shrink-0 items-center justify-between gap-2 border-b border-pdf-ctrl px-4 py-2"
|
|
||||||
>
|
|
||||||
<!-- Page navigation -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onclick={prevPage}
|
|
||||||
disabled={currentPage <= 1}
|
|
||||||
aria-label="Zurück"
|
|
||||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if totalPages > 0}
|
<PdfControls
|
||||||
<span class="font-sans text-xs text-ink-2 tabular-nums">
|
currentPage={renderer.currentPage}
|
||||||
{currentPage} / {totalPages}
|
totalPages={renderer.totalPages}
|
||||||
</span>
|
isLoaded={renderer.isLoaded}
|
||||||
{/if}
|
showAnnotations={showAnnotations}
|
||||||
|
annotationCount={annotations.length}
|
||||||
<button
|
onPrev={() => renderer.prevPage()}
|
||||||
onclick={nextPage}
|
onNext={() => renderer.nextPage()}
|
||||||
disabled={!pdfDoc || currentPage >= totalPages}
|
onZoomIn={() => renderer.zoomIn()}
|
||||||
aria-label="Weiter"
|
onZoomOut={() => renderer.zoomOut()}
|
||||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
onToggleAnnotations={() => (showAnnotations = !showAnnotations)}
|
||||||
>
|
/>
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Zoom controls -->
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onclick={zoomOut}
|
|
||||||
aria-label="Verkleinern"
|
|
||||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" /><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
d="M21 21l-4.35-4.35M8 11h6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={zoomIn}
|
|
||||||
aria-label="Vergrößern"
|
|
||||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" /><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
d="M21 21l-4.35-4.35M11 8v6M8 11h6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Annotation visibility toggle (shown when annotations exist) -->
|
|
||||||
{#if annotations.length > 0}
|
|
||||||
<button
|
|
||||||
onclick={() => (showAnnotations = !showAnnotations)}
|
|
||||||
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
|
||||||
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
|
||||||
? 'text-ink-2 hover:bg-surface/10'
|
|
||||||
: 'bg-surface/10 text-accent'}"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-3.5 w-3.5 shrink-0"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
{#if showAnnotations}
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</svg>
|
|
||||||
{showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PDF canvas area -->
|
<!-- PDF canvas area -->
|
||||||
<div class="relative flex-1 overflow-auto">
|
<div class="relative flex-1 overflow-auto">
|
||||||
{#if loading}
|
{#if renderer.loading}
|
||||||
<div class="flex h-full items-center justify-center">
|
<div class="flex h-full items-center justify-center">
|
||||||
<div
|
<div
|
||||||
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
|
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
|
||||||
@@ -490,7 +242,7 @@ function zoomOut() {
|
|||||||
<div class="flex min-h-full items-start justify-center p-4">
|
<div class="flex min-h-full items-start justify-center p-4">
|
||||||
<div
|
<div
|
||||||
class="pdf-page relative shadow-xl"
|
class="pdf-page relative shadow-xl"
|
||||||
data-page-number={currentPage}
|
data-page-number={renderer.currentPage}
|
||||||
style="position: relative"
|
style="position: relative"
|
||||||
>
|
>
|
||||||
<canvas bind:this={canvasEl}></canvas>
|
<canvas bind:this={canvasEl}></canvas>
|
||||||
@@ -501,7 +253,9 @@ function zoomOut() {
|
|||||||
></div>
|
></div>
|
||||||
{#if showAnnotations}
|
{#if showAnnotations}
|
||||||
<AnnotationLayer
|
<AnnotationLayer
|
||||||
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
|
annotations={visibleAnnotations.filter(
|
||||||
|
(a) => a.pageNumber === renderer.currentPage
|
||||||
|
)}
|
||||||
canDraw={transcribeMode}
|
canDraw={transcribeMode}
|
||||||
color={TRANSCRIPTION_COLOR}
|
color={TRANSCRIPTION_COLOR}
|
||||||
blockNumbers={blockNumbers}
|
blockNumbers={blockNumbers}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ let { person, abbreviated }: Props = $props();
|
|||||||
|
|
||||||
const name = $derived(abbreviated ? abbreviateName(person) : person.displayName);
|
const name = $derived(abbreviated ? abbreviateName(person) : person.displayName);
|
||||||
const avatarColor = $derived(personAvatarColor(person.id));
|
const avatarColor = $derived(personAvatarColor(person.id));
|
||||||
const initials = $derived(getInitials(person));
|
const initials = $derived(getInitials(person.displayName));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ interface Props {
|
|||||||
|
|
||||||
let { runs }: Props = $props();
|
let { runs }: Props = $props();
|
||||||
|
|
||||||
|
const COLLAPSED_COUNT = 3;
|
||||||
|
let expanded = $state(false);
|
||||||
|
|
||||||
|
const visibleRuns = $derived(expanded ? runs : runs.slice(0, COLLAPSED_COUNT));
|
||||||
|
const hasMore = $derived(runs.length > COLLAPSED_COUNT);
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -46,7 +52,7 @@ function formatCer(cer: number | undefined | null): string {
|
|||||||
<th class="hidden pb-2 text-right md:table-cell">{m.training_history_col_cer()}</th>
|
<th class="hidden pb-2 text-right md:table-cell">{m.training_history_col_cer()}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="training-history-rows">
|
||||||
{#if runs.length === 0}
|
{#if runs.length === 0}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="py-4 text-center text-sm text-ink-2">
|
<td colspan="5" class="py-4 text-center text-sm text-ink-2">
|
||||||
@@ -54,7 +60,7 @@ function formatCer(cer: number | undefined | null): string {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
{#each runs as run (run.id)}
|
{#each visibleRuns as run (run.id)}
|
||||||
<tr class="border-b border-line/50 last:border-0">
|
<tr class="border-b border-line/50 last:border-0">
|
||||||
<td class="py-2 text-ink-2">{formatDate(run.createdAt)}</td>
|
<td class="py-2 text-ink-2">{formatDate(run.createdAt)}</td>
|
||||||
<td class="py-2">
|
<td class="py-2">
|
||||||
@@ -79,7 +85,6 @@ function formatCer(cer: number | undefined | null): string {
|
|||||||
{:else if run.status === 'FAILED'}
|
{:else if run.status === 'FAILED'}
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1 rounded-sm bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700"
|
class="inline-flex items-center gap-1 rounded-sm bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700"
|
||||||
title={run.errorMessage}
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -95,13 +100,21 @@ function formatCer(cer: number | undefined | null): string {
|
|||||||
</svg>
|
</svg>
|
||||||
{m.training_status_failed()}
|
{m.training_status_failed()}
|
||||||
</span>
|
</span>
|
||||||
|
{#if run.errorMessage}
|
||||||
|
<details class="mt-0.5">
|
||||||
|
<summary class="cursor-pointer text-xs text-red-700 underline">
|
||||||
|
{m.training_error_detail_label()}
|
||||||
|
</summary>
|
||||||
|
<p class="mt-1 text-xs text-red-600">{run.errorMessage}</p>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1 rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-700"
|
class="inline-flex items-center gap-1 rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-700"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-500"
|
class="h-1.5 w-1.5 rounded-full bg-yellow-500 motion-safe:animate-pulse"
|
||||||
></span>
|
></span>
|
||||||
{m.training_status_running()}
|
{m.training_status_running()}
|
||||||
</span>
|
</span>
|
||||||
@@ -117,3 +130,17 @@ function formatCer(cer: number | undefined | null): string {
|
|||||||
{/if}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{#if hasMore}
|
||||||
|
<div class="mt-2 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls="training-history-rows"
|
||||||
|
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||||
|
onclick={() => (expanded = !expanded)}
|
||||||
|
>
|
||||||
|
{expanded ? m.comp_expandable_show_less() : m.comp_expandable_show_more()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
52
frontend/src/lib/components/TrainingHistory.svelte.spec.ts
Normal file
52
frontend/src/lib/components/TrainingHistory.svelte.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import TrainingHistory from './TrainingHistory.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function makeRun(i: number) {
|
||||||
|
return {
|
||||||
|
id: `run-${i}`,
|
||||||
|
status: 'DONE' as const,
|
||||||
|
blockCount: 10,
|
||||||
|
documentCount: 2,
|
||||||
|
modelName: 'german_kurrent',
|
||||||
|
createdAt: `2026-01-0${i + 1}T12:00:00Z`,
|
||||||
|
completedAt: `2026-01-0${i + 1}T12:05:00Z`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fiveRuns = Array.from({ length: 5 }, (_, i) => makeRun(i));
|
||||||
|
const twoRuns = Array.from({ length: 2 }, (_, i) => makeRun(i));
|
||||||
|
|
||||||
|
describe('TrainingHistory — expand/collapse', () => {
|
||||||
|
it('shows only 3 runs initially when more than 3 exist', async () => {
|
||||||
|
render(TrainingHistory, { runs: fiveRuns });
|
||||||
|
|
||||||
|
const rows = page.getByRole('row');
|
||||||
|
// 1 header row + 3 data rows = 4 total
|
||||||
|
await expect.element(rows.nth(3)).toBeInTheDocument();
|
||||||
|
await expect.element(rows.nth(4)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('button', { name: /Mehr anzeigen/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all runs after clicking the expand button', async () => {
|
||||||
|
render(TrainingHistory, { runs: fiveRuns });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Mehr anzeigen/i }).click();
|
||||||
|
|
||||||
|
const rows = page.getByRole('row');
|
||||||
|
// 1 header row + 5 data rows = 6 total
|
||||||
|
await expect.element(rows.nth(5)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the toggle button when 3 or fewer runs exist', async () => {
|
||||||
|
render(TrainingHistory, { runs: twoRuns });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /Mehr anzeigen/i }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
|
||||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||||
import OcrTrigger from './OcrTrigger.svelte';
|
import OcrTrigger from './OcrTrigger.svelte';
|
||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
|
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
|
||||||
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -45,6 +44,13 @@ let {
|
|||||||
|
|
||||||
let activeBlockId: string | null = $state(null);
|
let activeBlockId: string | null = $state(null);
|
||||||
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
||||||
|
let listEl: HTMLElement | null = $state(null);
|
||||||
|
|
||||||
|
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||||
|
const hasBlocks = $derived(blocks.length > 0);
|
||||||
|
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
|
||||||
|
const totalCount = $derived(blocks.length);
|
||||||
|
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
|
||||||
|
|
||||||
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
|
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -52,104 +58,37 @@ $effect(() => {
|
|||||||
const block = blocks.find((b) => b.annotationId === activeAnnotationId);
|
const block = blocks.find((b) => b.annotationId === activeAnnotationId);
|
||||||
if (block) activeBlockId = block.id;
|
if (block) activeBlockId = block.id;
|
||||||
});
|
});
|
||||||
let saveStates = new SvelteMap<string, SaveState>();
|
|
||||||
let debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
|
|
||||||
let pendingTexts = new SvelteMap<string, string>();
|
|
||||||
let sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
|
||||||
let hasBlocks = $derived(blocks.length > 0);
|
|
||||||
let reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
|
|
||||||
let totalCount = $derived(blocks.length);
|
|
||||||
let reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
|
|
||||||
|
|
||||||
function getSaveState(blockId: string): SaveState {
|
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
|
||||||
return saveStates.get(blockId) ?? 'idle';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSaveState(blockId: string, state: SaveState) {
|
const dragDrop = createBlockDragDrop({
|
||||||
saveStates.set(blockId, state);
|
getSortedBlocks: () => sortedBlocks,
|
||||||
}
|
onReorder: reorder
|
||||||
|
});
|
||||||
|
|
||||||
async function executeSave(blockId: string) {
|
// Wire listEl to drag-drop module
|
||||||
const text = pendingTexts.get(blockId);
|
$effect(() => {
|
||||||
if (text === undefined) return;
|
dragDrop.setListElement(listEl);
|
||||||
|
});
|
||||||
|
|
||||||
pendingTexts.delete(blockId);
|
$effect(() => {
|
||||||
setSaveState(blockId, 'saving');
|
function onBeforeUnload() {
|
||||||
|
autoSave.flushViaBeacon();
|
||||||
try {
|
|
||||||
await onSaveBlock(blockId, text);
|
|
||||||
setSaveState(blockId, 'saved');
|
|
||||||
scheduleSavedFade(blockId);
|
|
||||||
} catch {
|
|
||||||
setSaveState(blockId, 'error');
|
|
||||||
}
|
}
|
||||||
}
|
window.addEventListener('beforeunload', onBeforeUnload);
|
||||||
|
return () => {
|
||||||
function scheduleSavedFade(blockId: string) {
|
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||||
setTimeout(() => {
|
autoSave.destroy();
|
||||||
if (getSaveState(blockId) === 'saved') {
|
};
|
||||||
setSaveState(blockId, 'fading');
|
});
|
||||||
setTimeout(() => {
|
|
||||||
if (getSaveState(blockId) === 'fading') {
|
|
||||||
setSaveState(blockId, 'idle');
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleDebounce(blockId: string) {
|
|
||||||
clearDebounce(blockId);
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
debounceTimers.delete(blockId);
|
|
||||||
executeSave(blockId);
|
|
||||||
}, 1500);
|
|
||||||
debounceTimers.set(blockId, timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearDebounce(blockId: string) {
|
|
||||||
const existing = debounceTimers.get(blockId);
|
|
||||||
if (existing !== undefined) {
|
|
||||||
clearTimeout(existing);
|
|
||||||
debounceTimers.delete(blockId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushAllPending() {
|
|
||||||
for (const [blockId] of debounceTimers) {
|
|
||||||
clearDebounce(blockId);
|
|
||||||
executeSave(blockId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTextChange(blockId: string, text: string) {
|
|
||||||
pendingTexts.set(blockId, text);
|
|
||||||
scheduleDebounce(blockId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFocus(blockId: string) {
|
function handleFocus(blockId: string) {
|
||||||
activeBlockId = blockId;
|
activeBlockId = blockId;
|
||||||
onBlockFocus(blockId);
|
onBlockFocus(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBlur() {
|
|
||||||
flushAllPending();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRetry(blockId: string) {
|
|
||||||
const block = blocks.find((b) => b.id === blockId);
|
|
||||||
if (!block) return;
|
|
||||||
|
|
||||||
const pending = pendingTexts.get(blockId);
|
|
||||||
const text = pending ?? block.text;
|
|
||||||
pendingTexts.set(blockId, text);
|
|
||||||
await executeSave(blockId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete(blockId: string) {
|
function handleDelete(blockId: string) {
|
||||||
clearDebounce(blockId);
|
autoSave.clearBlock(blockId);
|
||||||
pendingTexts.delete(blockId);
|
|
||||||
saveStates.delete(blockId);
|
|
||||||
onDeleteBlock(blockId);
|
onDeleteBlock(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +101,6 @@ async function reorder(newOrder: string[]) {
|
|||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
// Update blocks with new sort orders from server
|
|
||||||
for (const b of updated) {
|
for (const b of updated) {
|
||||||
const existing = blocks.find((x) => x.id === b.id);
|
const existing = blocks.find((x) => x.id === b.id);
|
||||||
if (existing) existing.sortOrder = b.sortOrder;
|
if (existing) existing.sortOrder = b.sortOrder;
|
||||||
@@ -188,69 +126,9 @@ function handleMoveDown(blockId: string) {
|
|||||||
reorder(sorted.map((b) => b.id));
|
reorder(sorted.map((b) => b.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pointer-based drag and drop ──────────────────────────────────────────
|
|
||||||
|
|
||||||
let draggedBlockId: string | null = $state(null);
|
|
||||||
let dropTargetIdx: number | null = $state(null);
|
|
||||||
let dragOffsetY: number = $state(0);
|
|
||||||
let dragStartY = 0;
|
|
||||||
let capturedEl: HTMLElement | null = null;
|
|
||||||
let listEl: HTMLElement | null = $state(null);
|
|
||||||
|
|
||||||
function handleGripDown(e: PointerEvent, blockId: string) {
|
|
||||||
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
|
|
||||||
e.preventDefault();
|
|
||||||
draggedBlockId = blockId;
|
|
||||||
dragStartY = e.clientY;
|
|
||||||
dragOffsetY = 0;
|
|
||||||
capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement;
|
|
||||||
capturedEl?.setPointerCapture(e.pointerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerMove(e: PointerEvent) {
|
|
||||||
if (!draggedBlockId || !listEl) return;
|
|
||||||
dragOffsetY = e.clientY - dragStartY;
|
|
||||||
|
|
||||||
const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]'));
|
|
||||||
const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId);
|
|
||||||
let target: number | null = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < wrappers.length; i++) {
|
|
||||||
const rect = wrappers[i].getBoundingClientRect();
|
|
||||||
if (e.clientY < rect.top + rect.height / 2) {
|
|
||||||
target = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (target === null) target = wrappers.length;
|
|
||||||
if (target === dragIdx || target === dragIdx + 1) target = null;
|
|
||||||
dropTargetIdx = target;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerUp() {
|
|
||||||
if (!draggedBlockId) return;
|
|
||||||
|
|
||||||
if (dropTargetIdx !== null) {
|
|
||||||
const sorted = [...sortedBlocks];
|
|
||||||
const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId);
|
|
||||||
if (fromIdx >= 0) {
|
|
||||||
const [moved] = sorted.splice(fromIdx, 1);
|
|
||||||
const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx;
|
|
||||||
sorted.splice(insertAt, 0, moved);
|
|
||||||
reorder(sorted.map((b) => b.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
draggedBlockId = null;
|
|
||||||
dropTargetIdx = null;
|
|
||||||
dragOffsetY = 0;
|
|
||||||
capturedEl = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLabelToggle(label: string) {
|
async function handleLabelToggle(label: string) {
|
||||||
if (!onToggleTrainingLabel) return;
|
if (!onToggleTrainingLabel) return;
|
||||||
const enrolled = !localLabels.includes(label);
|
const enrolled = !localLabels.includes(label);
|
||||||
// Optimistic update
|
|
||||||
if (enrolled) {
|
if (enrolled) {
|
||||||
localLabels = [...localLabels, label];
|
localLabels = [...localLabels, label];
|
||||||
} else {
|
} else {
|
||||||
@@ -259,35 +137,9 @@ async function handleLabelToggle(label: string) {
|
|||||||
try {
|
try {
|
||||||
await onToggleTrainingLabel(label, enrolled);
|
await onToggleTrainingLabel(label, enrolled);
|
||||||
} catch {
|
} catch {
|
||||||
// Revert on failure
|
|
||||||
localLabels = [...trainingLabels];
|
localLabels = [...trainingLabels];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushViaBeacon() {
|
|
||||||
for (const [blockId, text] of pendingTexts) {
|
|
||||||
clearDebounce(blockId);
|
|
||||||
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
|
|
||||||
const body = JSON.stringify({ text });
|
|
||||||
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
|
|
||||||
pendingTexts.delete(blockId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
function onBeforeUnload() {
|
|
||||||
flushViaBeacon();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', onBeforeUnload);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
||||||
for (const timer of debounceTimers.values()) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col overflow-y-auto bg-surface">
|
<div class="flex h-full flex-col overflow-y-auto bg-surface">
|
||||||
@@ -309,20 +161,22 @@ $effect(() => {
|
|||||||
<div
|
<div
|
||||||
class="flex flex-col gap-3"
|
class="flex flex-col gap-3"
|
||||||
bind:this={listEl}
|
bind:this={listEl}
|
||||||
onpointermove={handlePointerMove}
|
onpointermove={dragDrop.handlePointerMove}
|
||||||
onpointerup={handlePointerUp}
|
onpointerup={dragDrop.handlePointerUp}
|
||||||
>
|
>
|
||||||
{#each sortedBlocks as block, i (block.id)}
|
{#each sortedBlocks as block, i (block.id)}
|
||||||
{#if dropTargetIdx === i}
|
{#if dragDrop.dropTargetIdx === i}
|
||||||
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
data-block-wrapper
|
data-block-wrapper
|
||||||
onblur={handleBlur}
|
onblur={autoSave.handleBlur}
|
||||||
onpointerdown={(e) => handleGripDown(e, block.id)}
|
onpointerdown={(e) => dragDrop.handleGripDown(e, block.id)}
|
||||||
class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
|
class="relative transition-all duration-150 {dragDrop.draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
|
||||||
style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''}
|
style={dragDrop.draggedBlockId === block.id
|
||||||
|
? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
|
||||||
|
: ''}
|
||||||
>
|
>
|
||||||
<TranscriptionBlock
|
<TranscriptionBlock
|
||||||
blockId={block.id}
|
blockId={block.id}
|
||||||
@@ -332,13 +186,13 @@ $effect(() => {
|
|||||||
label={block.label}
|
label={block.label}
|
||||||
active={activeBlockId === block.id}
|
active={activeBlockId === block.id}
|
||||||
reviewed={block.reviewed ?? false}
|
reviewed={block.reviewed ?? false}
|
||||||
saveState={getSaveState(block.id)}
|
saveState={autoSave.getSaveState(block.id)}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onTextChange={(text) => handleTextChange(block.id, text)}
|
onTextChange={(text) => autoSave.handleTextChange(block.id, text)}
|
||||||
onFocus={() => handleFocus(block.id)}
|
onFocus={() => handleFocus(block.id)}
|
||||||
onDeleteClick={() => handleDelete(block.id)}
|
onDeleteClick={() => handleDelete(block.id)}
|
||||||
onRetry={() => handleRetry(block.id)}
|
onRetry={() => autoSave.handleRetry(block.id, block.text)}
|
||||||
onReviewToggle={() => onReviewToggle(block.id)}
|
onReviewToggle={() => onReviewToggle(block.id)}
|
||||||
onMoveUp={() => handleMoveUp(block.id)}
|
onMoveUp={() => handleMoveUp(block.id)}
|
||||||
onMoveDown={() => handleMoveDown(block.id)}
|
onMoveDown={() => handleMoveDown(block.id)}
|
||||||
@@ -349,7 +203,7 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if dropTargetIdx === sortedBlocks.length}
|
{#if dragDrop.dropTargetIdx === sortedBlocks.length}
|
||||||
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
22
frontend/src/lib/components/UnsavedWarningBanner.svelte
Normal file
22
frontend/src/lib/components/UnsavedWarningBanner.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onDiscard: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onDiscard }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
<span>{m.admin_unsaved_warning()}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onDiscard}
|
||||||
|
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
{m.person_discard_changes()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise<void>>();
|
||||||
|
|
||||||
|
const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte');
|
||||||
|
|
||||||
|
describe('createBlockAutoSave', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mockSaveFn.mockClear();
|
||||||
|
mockSaveFn.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSaveState returns idle initially', () => {
|
||||||
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
|
expect(as.getSaveState('block-1')).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debounce coalesces multiple changes — saves once after 1500ms', async () => {
|
||||||
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
|
as.handleTextChange('block-1', 'text 1');
|
||||||
|
as.handleTextChange('block-1', 'text 2');
|
||||||
|
as.handleTextChange('block-1', 'text 3');
|
||||||
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
|
expect(mockSaveFn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles concurrent blocks independently', async () => {
|
||||||
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
|
as.handleTextChange('block-1', 'hello');
|
||||||
|
as.handleTextChange('block-2', 'world');
|
||||||
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
|
expect(mockSaveFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets save state to saving then saved on success', async () => {
|
||||||
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
|
as.handleTextChange('block-1', 'text');
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
expect(as.getSaveState('block-1')).toBe('saving');
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(as.getSaveState('block-1')).toBe('saved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets save state to error on save failure', async () => {
|
||||||
|
mockSaveFn.mockRejectedValue(new Error('save failed'));
|
||||||
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
|
as.handleTextChange('block-1', 'text');
|
||||||
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
|
expect(as.getSaveState('block-1')).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleRetry saves with provided current text', async () => {
|
||||||
|
mockSaveFn.mockRejectedValueOnce(new Error('first fails'));
|
||||||
|
mockSaveFn.mockResolvedValueOnce(undefined);
|
||||||
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
|
as.handleTextChange('block-1', 'original');
|
||||||
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
|
expect(as.getSaveState('block-1')).toBe('error');
|
||||||
|
await as.handleRetry('block-1', 'original');
|
||||||
|
expect(mockSaveFn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(as.getSaveState('block-1')).toBe('saved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearBlock removes all state for a block', () => {
|
||||||
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
|
as.handleTextChange('block-1', 'text');
|
||||||
|
as.clearBlock('block-1');
|
||||||
|
expect(as.getSaveState('block-1')).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroy clears all pending timers so no save occurs', async () => {
|
||||||
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
|
as.handleTextChange('block-1', 'text');
|
||||||
|
as.destroy();
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(mockSaveFn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
168
frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts
Normal file
168
frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { createBlockDragDrop } from '../useBlockDragDrop.svelte';
|
||||||
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
|
|
||||||
|
function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
annotationId: `ann-${id}`,
|
||||||
|
documentId: 'doc-1',
|
||||||
|
text: '',
|
||||||
|
label: null,
|
||||||
|
sortOrder,
|
||||||
|
version: 1,
|
||||||
|
source: 'MANUAL',
|
||||||
|
reviewed: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a DOM list, mocks getBoundingClientRect (60px per wrapper),
|
||||||
|
* drags `dragId` and drops it so dropTargetIdx === targetIdx, then
|
||||||
|
* triggers handlePointerUp. Returns the onReorder spy.
|
||||||
|
*/
|
||||||
|
function simulateDragDrop(
|
||||||
|
dragId: string,
|
||||||
|
targetIdx: number,
|
||||||
|
blocks: TranscriptionBlockData[]
|
||||||
|
): ReturnType<typeof vi.fn> {
|
||||||
|
const onReorder = vi.fn();
|
||||||
|
const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
|
||||||
|
|
||||||
|
// Build DOM
|
||||||
|
const listEl = document.createElement('div');
|
||||||
|
const wrappers = blocks.map(() => {
|
||||||
|
const grip = document.createElement('div');
|
||||||
|
grip.setAttribute('data-drag-handle', '');
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.setAttribute('data-block-wrapper', '');
|
||||||
|
wrapper.appendChild(grip);
|
||||||
|
listEl.appendChild(wrapper);
|
||||||
|
return { grip, wrapper };
|
||||||
|
});
|
||||||
|
document.body.appendChild(listEl);
|
||||||
|
dd.setListElement(listEl);
|
||||||
|
|
||||||
|
// Mock bounding rects: each wrapper is 60px tall starting at y=0
|
||||||
|
wrappers.forEach(({ wrapper }, i) => {
|
||||||
|
vi.spyOn(wrapper, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
top: i * 60,
|
||||||
|
height: 60,
|
||||||
|
bottom: (i + 1) * 60,
|
||||||
|
left: 0,
|
||||||
|
right: 100,
|
||||||
|
width: 100,
|
||||||
|
x: 0,
|
||||||
|
y: i * 60,
|
||||||
|
toJSON: () => ({})
|
||||||
|
} as DOMRect);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dragIdx = blocks.findIndex((b) => b.id === dragId);
|
||||||
|
const { grip, wrapper: dragWrapper } = wrappers[dragIdx];
|
||||||
|
dragWrapper.setPointerCapture = vi.fn();
|
||||||
|
|
||||||
|
// Start drag
|
||||||
|
const downEvent = new PointerEvent('pointerdown', { clientY: dragIdx * 60, cancelable: true });
|
||||||
|
Object.defineProperty(downEvent, 'target', { value: grip });
|
||||||
|
dd.handleGripDown(downEvent as PointerEvent, dragId);
|
||||||
|
|
||||||
|
// Move pointer to achieve the desired targetIdx
|
||||||
|
// midpoint of wrapper[i] = i*60 + 30
|
||||||
|
// clientY just before midpoint[i] → target = i
|
||||||
|
// clientY past last midpoint → target = wrappers.length
|
||||||
|
let clientY: number;
|
||||||
|
if (targetIdx <= 0) {
|
||||||
|
clientY = 5; // before first midpoint (30)
|
||||||
|
} else if (targetIdx >= wrappers.length) {
|
||||||
|
clientY = wrappers.length * 60 + 10; // past all midpoints
|
||||||
|
} else {
|
||||||
|
clientY = targetIdx * 60 + 5; // just past top of wrapper[targetIdx], before its midpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveEvent = new PointerEvent('pointermove', { clientY });
|
||||||
|
dd.handlePointerMove(moveEvent as PointerEvent);
|
||||||
|
dd.handlePointerUp();
|
||||||
|
|
||||||
|
document.body.removeChild(listEl);
|
||||||
|
return onReorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createBlockDragDrop', () => {
|
||||||
|
it('initial state — no drag in progress', () => {
|
||||||
|
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
|
||||||
|
expect(dd.draggedBlockId).toBeNull();
|
||||||
|
expect(dd.dropTargetIdx).toBeNull();
|
||||||
|
expect(dd.dragOffsetY).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleGripDown sets draggedBlockId when grip is hit', () => {
|
||||||
|
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
|
||||||
|
const grip = document.createElement('div');
|
||||||
|
grip.setAttribute('data-drag-handle', '');
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.setAttribute('data-block-wrapper', '');
|
||||||
|
wrapper.appendChild(grip);
|
||||||
|
document.body.appendChild(wrapper);
|
||||||
|
|
||||||
|
const e = new PointerEvent('pointerdown', { clientY: 100, cancelable: true, bubbles: true });
|
||||||
|
Object.defineProperty(e, 'target', { value: grip });
|
||||||
|
wrapper.setPointerCapture = vi.fn();
|
||||||
|
|
||||||
|
dd.handleGripDown(e as PointerEvent, 'block-1');
|
||||||
|
expect(dd.draggedBlockId).toBe('block-1');
|
||||||
|
|
||||||
|
document.body.removeChild(wrapper);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handlePointerUp without active drag is a no-op', () => {
|
||||||
|
const onReorder = vi.fn();
|
||||||
|
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder });
|
||||||
|
dd.handlePointerUp();
|
||||||
|
expect(onReorder).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handlePointerUp with null dropTargetIdx does not call onReorder', () => {
|
||||||
|
const onReorder = vi.fn();
|
||||||
|
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1)];
|
||||||
|
const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
|
||||||
|
|
||||||
|
const grip = document.createElement('div');
|
||||||
|
grip.setAttribute('data-drag-handle', '');
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.setAttribute('data-block-wrapper', '');
|
||||||
|
wrapper.appendChild(grip);
|
||||||
|
document.body.appendChild(wrapper);
|
||||||
|
wrapper.setPointerCapture = vi.fn();
|
||||||
|
|
||||||
|
const downEvent = new PointerEvent('pointerdown', { clientY: 50, cancelable: true });
|
||||||
|
Object.defineProperty(downEvent, 'target', { value: grip });
|
||||||
|
dd.handleGripDown(downEvent as PointerEvent, 'b1');
|
||||||
|
|
||||||
|
// dropTargetIdx is still null (no pointer move happened)
|
||||||
|
dd.handlePointerUp();
|
||||||
|
expect(onReorder).not.toHaveBeenCalled();
|
||||||
|
expect(dd.draggedBlockId).toBeNull();
|
||||||
|
|
||||||
|
document.body.removeChild(wrapper);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorder: moves block from index 0 to end', () => {
|
||||||
|
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
|
||||||
|
const onReorder = simulateDragDrop('b1', 3, blocks);
|
||||||
|
expect(onReorder).toHaveBeenCalledWith(['b2', 'b3', 'b1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorder: moves block from end to index 0', () => {
|
||||||
|
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
|
||||||
|
const onReorder = simulateDragDrop('b3', 0, blocks);
|
||||||
|
expect(onReorder).toHaveBeenCalledWith(['b3', 'b1', 'b2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorder: moves block down by one position (tests insertAt = dropTargetIdx - 1)', () => {
|
||||||
|
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
|
||||||
|
// dragId=b1 (idx=0), targetIdx=2 → insertAt = 2-1 = 1 → [b2, b1, b3]
|
||||||
|
const onReorder = simulateDragDrop('b1', 2, blocks);
|
||||||
|
expect(onReorder).toHaveBeenCalledWith(['b2', 'b1', 'b3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { createFileLoader } from '../useFileLoader.svelte';
|
||||||
|
|
||||||
|
const FAKE_URL = 'blob:fake-url';
|
||||||
|
|
||||||
|
function setupFetch(ok: boolean, body?: Blob) {
|
||||||
|
const blob = body ?? new Blob(['fake'], { type: 'application/pdf' });
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok,
|
||||||
|
blob: vi.fn().mockResolvedValue(blob)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createFileLoader', () => {
|
||||||
|
it('sets fileUrl after a successful fetch', async () => {
|
||||||
|
vi.stubGlobal('URL', {
|
||||||
|
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
|
||||||
|
revokeObjectURL: vi.fn()
|
||||||
|
});
|
||||||
|
setupFetch(true);
|
||||||
|
|
||||||
|
const loader = createFileLoader();
|
||||||
|
await loader.loadFile('/api/documents/1/file');
|
||||||
|
|
||||||
|
expect(loader.fileUrl).toBe(FAKE_URL);
|
||||||
|
expect(loader.isLoading).toBe(false);
|
||||||
|
expect(loader.fileError).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets fileError on a failed fetch (non-ok response)', async () => {
|
||||||
|
vi.stubGlobal('URL', {
|
||||||
|
createObjectURL: vi.fn(),
|
||||||
|
revokeObjectURL: vi.fn()
|
||||||
|
});
|
||||||
|
setupFetch(false);
|
||||||
|
|
||||||
|
const loader = createFileLoader();
|
||||||
|
await loader.loadFile('/api/documents/1/file');
|
||||||
|
|
||||||
|
expect(loader.fileUrl).toBe('');
|
||||||
|
expect(loader.fileError).not.toBe('');
|
||||||
|
expect(loader.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revokes the previous URL before creating a new one', async () => {
|
||||||
|
const revokeObjectURL = vi.fn();
|
||||||
|
vi.stubGlobal('URL', {
|
||||||
|
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
|
||||||
|
revokeObjectURL
|
||||||
|
});
|
||||||
|
setupFetch(true);
|
||||||
|
|
||||||
|
const loader = createFileLoader();
|
||||||
|
await loader.loadFile('/api/documents/1/file');
|
||||||
|
// First load: no previous URL to revoke
|
||||||
|
expect(revokeObjectURL).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await loader.loadFile('/api/documents/2/file');
|
||||||
|
// Second load: previous URL should be revoked
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revokes the URL on destroy', async () => {
|
||||||
|
const revokeObjectURL = vi.fn();
|
||||||
|
vi.stubGlobal('URL', {
|
||||||
|
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
|
||||||
|
revokeObjectURL
|
||||||
|
});
|
||||||
|
setupFetch(true);
|
||||||
|
|
||||||
|
const loader = createFileLoader();
|
||||||
|
await loader.loadFile('/api/documents/1/file');
|
||||||
|
loader.destroy();
|
||||||
|
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not revoke when no URL has been set', () => {
|
||||||
|
const revokeObjectURL = vi.fn();
|
||||||
|
vi.stubGlobal('URL', {
|
||||||
|
createObjectURL: vi.fn(),
|
||||||
|
revokeObjectURL
|
||||||
|
});
|
||||||
|
|
||||||
|
const loader = createFileLoader();
|
||||||
|
loader.destroy();
|
||||||
|
|
||||||
|
expect(revokeObjectURL).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import type { NotificationItem } from '../useNotificationStream.svelte';
|
||||||
|
|
||||||
|
// Track the last created EventSource instance
|
||||||
|
let lastEventSource: {
|
||||||
|
close: ReturnType<typeof vi.fn>;
|
||||||
|
onopen: (() => void) | null;
|
||||||
|
onerror: (() => void) | null;
|
||||||
|
simulate: (type: string, data: string) => void;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
class MockEventSource {
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
close = vi.fn();
|
||||||
|
private listeners: Record<string, ((e: MessageEvent) => void)[]> = {};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||||
|
lastEventSource = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, fn: (e: MessageEvent) => void) {
|
||||||
|
if (!this.listeners[type]) this.listeners[type] = [];
|
||||||
|
this.listeners[type].push(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
simulate(type: string, data: string) {
|
||||||
|
const event = new MessageEvent(type, { data });
|
||||||
|
for (const fn of this.listeners[type] ?? []) {
|
||||||
|
fn(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal('EventSource', MockEventSource);
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
// Import after stubs are set up
|
||||||
|
const { createNotificationStream } = await import('../useNotificationStream.svelte');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
lastEventSource = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem {
|
||||||
|
return {
|
||||||
|
id: 'n1',
|
||||||
|
type: 'REPLY',
|
||||||
|
actorName: 'Hans',
|
||||||
|
documentId: 'doc-1',
|
||||||
|
referenceId: 'ref-1',
|
||||||
|
annotationId: null,
|
||||||
|
read: false,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createNotificationStream', () => {
|
||||||
|
it('starts with empty notifications and zero unreadCount', () => {
|
||||||
|
const stream = createNotificationStream();
|
||||||
|
expect(stream.notifications).toHaveLength(0);
|
||||||
|
expect(stream.unreadCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchUnreadCount updates unreadCount from API', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ count: 3 }), { status: 200 }));
|
||||||
|
const stream = createNotificationStream();
|
||||||
|
await stream.fetchUnreadCount();
|
||||||
|
expect(stream.unreadCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchNotifications populates notifications from API', async () => {
|
||||||
|
const items = [makeNotification()];
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ content: items }), { status: 200 })
|
||||||
|
);
|
||||||
|
const stream = createNotificationStream();
|
||||||
|
await stream.fetchNotifications();
|
||||||
|
expect(stream.notifications).toHaveLength(1);
|
||||||
|
expect(stream.notifications[0].id).toBe('n1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markRead marks notification as read and decrements unreadCount', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ count: 2 }), { status: 200 }))
|
||||||
|
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||||
|
const stream = createNotificationStream();
|
||||||
|
await stream.fetchUnreadCount();
|
||||||
|
|
||||||
|
const notification = makeNotification({ read: false });
|
||||||
|
await stream.markRead(notification);
|
||||||
|
expect(notification.read).toBe(true);
|
||||||
|
expect(stream.unreadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markAllRead calls the API and resets unreadCount', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||||
|
const stream = createNotificationStream();
|
||||||
|
await stream.markAllRead();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
|
||||||
|
expect(stream.unreadCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroy closes the EventSource', async () => {
|
||||||
|
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
|
||||||
|
const stream = createNotificationStream();
|
||||||
|
stream.init();
|
||||||
|
expect(lastEventSource).not.toBeNull();
|
||||||
|
stream.destroy();
|
||||||
|
expect(lastEventSource!.close).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SSE notification event prepends notification and increments unreadCount', async () => {
|
||||||
|
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
|
||||||
|
const stream = createNotificationStream();
|
||||||
|
stream.init();
|
||||||
|
|
||||||
|
const notification = makeNotification({ id: 'sse-1', read: false });
|
||||||
|
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||||
|
|
||||||
|
expect(stream.notifications).toHaveLength(1);
|
||||||
|
expect(stream.notifications[0].id).toBe('sse-1');
|
||||||
|
expect(stream.unreadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SSE notification event with read:true does not increment unreadCount', async () => {
|
||||||
|
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
|
||||||
|
const stream = createNotificationStream();
|
||||||
|
stream.init();
|
||||||
|
|
||||||
|
const notification = makeNotification({ id: 'sse-2', read: true });
|
||||||
|
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||||
|
|
||||||
|
expect(stream.notifications).toHaveLength(1);
|
||||||
|
expect(stream.unreadCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createPdfRenderer } from '../usePdfRenderer.svelte';
|
||||||
|
|
||||||
|
// Note: init() and loadDocument() require pdfjsLib (browser module).
|
||||||
|
// These tests cover pure state logic only — bounds clamping and zoom limits.
|
||||||
|
|
||||||
|
describe('createPdfRenderer', () => {
|
||||||
|
it('starts at page 1 with scale 1.5 and no error', () => {
|
||||||
|
const r = createPdfRenderer();
|
||||||
|
expect(r.currentPage).toBe(1);
|
||||||
|
expect(r.scale).toBe(1.5);
|
||||||
|
expect(r.totalPages).toBe(0);
|
||||||
|
expect(r.loading).toBe(false);
|
||||||
|
expect(r.error).toBeNull();
|
||||||
|
expect(r.isLoaded).toBe(false);
|
||||||
|
expect(r.pdfjsReady).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevPage does not go below page 1', () => {
|
||||||
|
const r = createPdfRenderer();
|
||||||
|
r.prevPage();
|
||||||
|
expect(r.currentPage).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nextPage does not exceed totalPages', () => {
|
||||||
|
const r = createPdfRenderer();
|
||||||
|
// totalPages = 0, so 1 < 0 is false → stays at 1
|
||||||
|
r.nextPage();
|
||||||
|
expect(r.currentPage).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goToPage does not navigate when n > totalPages', () => {
|
||||||
|
const r = createPdfRenderer();
|
||||||
|
r.goToPage(5);
|
||||||
|
expect(r.currentPage).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goToPage does not navigate when n < 1', () => {
|
||||||
|
const r = createPdfRenderer();
|
||||||
|
r.goToPage(0);
|
||||||
|
expect(r.currentPage).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zoomIn increases scale by 0.25', () => {
|
||||||
|
const r = createPdfRenderer();
|
||||||
|
r.zoomIn();
|
||||||
|
expect(r.scale).toBeCloseTo(1.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zoomOut decreases scale by 0.25', () => {
|
||||||
|
const r = createPdfRenderer();
|
||||||
|
r.zoomOut();
|
||||||
|
expect(r.scale).toBeCloseTo(1.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zoomOut does not go below 0.5', () => {
|
||||||
|
const r = createPdfRenderer();
|
||||||
|
for (let i = 0; i < 20; i++) r.zoomOut();
|
||||||
|
expect(r.scale).toBeCloseTo(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadDocument is a no-op when pdfjsLib not initialized', async () => {
|
||||||
|
const r = createPdfRenderer();
|
||||||
|
await r.loadDocument('/some/path');
|
||||||
|
// No-op because pdfjsLib is null (init not called)
|
||||||
|
expect(r.error).toBeNull();
|
||||||
|
expect(r.loading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Capture the beforeNavigate callback so tests can simulate navigation events
|
||||||
|
let registeredBeforeNavigate:
|
||||||
|
| ((nav: { cancel: () => void; to: { url: { href: string } } | null }) => void)
|
||||||
|
| null = null;
|
||||||
|
|
||||||
|
const mockGoto = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: vi.fn((fn: typeof registeredBeforeNavigate) => {
|
||||||
|
registeredBeforeNavigate = fn;
|
||||||
|
}),
|
||||||
|
goto: mockGoto
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createUnsavedWarning } = await import('../useUnsavedWarning.svelte');
|
||||||
|
|
||||||
|
function simulateNavigate(href: string | null = '/somewhere') {
|
||||||
|
const cancel = vi.fn();
|
||||||
|
registeredBeforeNavigate?.({
|
||||||
|
cancel,
|
||||||
|
to: href ? { url: { href } } : null
|
||||||
|
});
|
||||||
|
return cancel;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registeredBeforeNavigate = null;
|
||||||
|
mockGoto.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createUnsavedWarning', () => {
|
||||||
|
it('isDirty starts false', () => {
|
||||||
|
const w = createUnsavedWarning();
|
||||||
|
expect(w.isDirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markDirty sets isDirty to true', () => {
|
||||||
|
const w = createUnsavedWarning();
|
||||||
|
w.markDirty();
|
||||||
|
expect(w.isDirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markDirty hides any existing warning banner', () => {
|
||||||
|
const w = createUnsavedWarning();
|
||||||
|
// Simulate a navigation event that showed the banner
|
||||||
|
w.markDirty();
|
||||||
|
simulateNavigate();
|
||||||
|
expect(w.showUnsavedWarning).toBe(true);
|
||||||
|
// Typing again should hide the banner (form input re-triggers markDirty)
|
||||||
|
w.markDirty();
|
||||||
|
expect(w.showUnsavedWarning).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beforeNavigate cancels and shows banner when dirty', () => {
|
||||||
|
const w = createUnsavedWarning();
|
||||||
|
w.markDirty();
|
||||||
|
const cancel = simulateNavigate('/admin/users');
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
expect(w.showUnsavedWarning).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beforeNavigate stores the target URL', () => {
|
||||||
|
const w = createUnsavedWarning();
|
||||||
|
w.markDirty();
|
||||||
|
simulateNavigate('/admin/users');
|
||||||
|
expect(w.discardTarget).toBe('/admin/users');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beforeNavigate does not cancel when not dirty', () => {
|
||||||
|
createUnsavedWarning();
|
||||||
|
const cancel = simulateNavigate('/admin/users');
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discard resets state and navigates to target', () => {
|
||||||
|
const w = createUnsavedWarning();
|
||||||
|
w.markDirty();
|
||||||
|
simulateNavigate('/admin/tags');
|
||||||
|
w.discard();
|
||||||
|
expect(w.isDirty).toBe(false);
|
||||||
|
expect(w.showUnsavedWarning).toBe(false);
|
||||||
|
expect(mockGoto).toHaveBeenCalledWith('/admin/tags');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearOnSuccess resets isDirty and warning', () => {
|
||||||
|
const w = createUnsavedWarning();
|
||||||
|
w.markDirty();
|
||||||
|
simulateNavigate('/somewhere');
|
||||||
|
w.clearOnSuccess();
|
||||||
|
expect(w.isDirty).toBe(false);
|
||||||
|
expect(w.showUnsavedWarning).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
127
frontend/src/lib/hooks/useBlockAutoSave.svelte.ts
Normal file
127
frontend/src/lib/hooks/useBlockAutoSave.svelte.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
saveFn: (blockId: string, text: string) => Promise<void>;
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
||||||
|
const saveStates = new SvelteMap<string, SaveState>();
|
||||||
|
const debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
|
||||||
|
const pendingTexts = new SvelteMap<string, string>();
|
||||||
|
const fadeTimers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
|
function getSaveState(blockId: string): SaveState {
|
||||||
|
return saveStates.get(blockId) ?? 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSaveState(blockId: string, state: SaveState) {
|
||||||
|
saveStates.set(blockId, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeSave(blockId: string): Promise<void> {
|
||||||
|
const text = pendingTexts.get(blockId);
|
||||||
|
if (text === undefined) return;
|
||||||
|
|
||||||
|
pendingTexts.delete(blockId);
|
||||||
|
setSaveState(blockId, 'saving');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveFn(blockId, text);
|
||||||
|
setSaveState(blockId, 'saved');
|
||||||
|
scheduleSavedFade(blockId);
|
||||||
|
} catch {
|
||||||
|
setSaveState(blockId, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSavedFade(blockId: string): void {
|
||||||
|
const t1 = setTimeout(() => {
|
||||||
|
if (getSaveState(blockId) === 'saved') {
|
||||||
|
setSaveState(blockId, 'fading');
|
||||||
|
const t2 = setTimeout(() => {
|
||||||
|
if (getSaveState(blockId) === 'fading') {
|
||||||
|
setSaveState(blockId, 'idle');
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
fadeTimers.push(t2);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
fadeTimers.push(t1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDebounce(blockId: string): void {
|
||||||
|
clearDebounce(blockId);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
debounceTimers.delete(blockId);
|
||||||
|
executeSave(blockId);
|
||||||
|
}, 1500);
|
||||||
|
debounceTimers.set(blockId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDebounce(blockId: string): void {
|
||||||
|
const existing = debounceTimers.get(blockId);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
clearTimeout(existing);
|
||||||
|
debounceTimers.delete(blockId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTextChange(blockId: string, text: string): void {
|
||||||
|
pendingTexts.set(blockId, text);
|
||||||
|
scheduleDebounce(blockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur(): void {
|
||||||
|
for (const [blockId] of [...debounceTimers]) {
|
||||||
|
clearDebounce(blockId);
|
||||||
|
executeSave(blockId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRetry(blockId: string, currentText: string): Promise<void> {
|
||||||
|
const pending = pendingTexts.get(blockId);
|
||||||
|
const text = pending ?? currentText;
|
||||||
|
pendingTexts.set(blockId, text);
|
||||||
|
await executeSave(blockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBlock(blockId: string): void {
|
||||||
|
clearDebounce(blockId);
|
||||||
|
pendingTexts.delete(blockId);
|
||||||
|
saveStates.delete(blockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushViaBeacon(): void {
|
||||||
|
for (const [blockId, text] of pendingTexts) {
|
||||||
|
clearDebounce(blockId);
|
||||||
|
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
|
||||||
|
const body = JSON.stringify({ text });
|
||||||
|
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
|
||||||
|
pendingTexts.delete(blockId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy(): void {
|
||||||
|
for (const timer of debounceTimers.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
debounceTimers.clear();
|
||||||
|
for (const timer of fadeTimers) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
fadeTimers.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getSaveState,
|
||||||
|
handleTextChange,
|
||||||
|
handleBlur,
|
||||||
|
handleRetry,
|
||||||
|
clearBlock,
|
||||||
|
flushViaBeacon,
|
||||||
|
destroy
|
||||||
|
};
|
||||||
|
}
|
||||||
88
frontend/src/lib/hooks/useBlockDragDrop.svelte.ts
Normal file
88
frontend/src/lib/hooks/useBlockDragDrop.svelte.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
getSortedBlocks: () => TranscriptionBlockData[];
|
||||||
|
onReorder: (blockIds: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createBlockDragDrop({ getSortedBlocks, onReorder }: Options) {
|
||||||
|
let draggedBlockId = $state<string | null>(null);
|
||||||
|
let dropTargetIdx = $state<number | null>(null);
|
||||||
|
let dragOffsetY = $state(0);
|
||||||
|
|
||||||
|
// Internal mutable refs — not reactive
|
||||||
|
let dragStartY = 0;
|
||||||
|
let capturedEl: HTMLElement | null = null;
|
||||||
|
let listEl: HTMLElement | null = null;
|
||||||
|
|
||||||
|
function setListElement(el: HTMLElement | null): void {
|
||||||
|
listEl = el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGripDown(e: PointerEvent, blockId: string): void {
|
||||||
|
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
draggedBlockId = blockId;
|
||||||
|
dragStartY = e.clientY;
|
||||||
|
dragOffsetY = 0;
|
||||||
|
capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement;
|
||||||
|
capturedEl?.setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(e: PointerEvent): void {
|
||||||
|
if (!draggedBlockId || !listEl) return;
|
||||||
|
dragOffsetY = e.clientY - dragStartY;
|
||||||
|
|
||||||
|
const sortedBlocks = getSortedBlocks();
|
||||||
|
const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]'));
|
||||||
|
const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId);
|
||||||
|
let target: number | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < wrappers.length; i++) {
|
||||||
|
const rect = wrappers[i].getBoundingClientRect();
|
||||||
|
if (e.clientY < rect.top + rect.height / 2) {
|
||||||
|
target = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target === null) target = wrappers.length;
|
||||||
|
if (target === dragIdx || target === dragIdx + 1) target = null;
|
||||||
|
dropTargetIdx = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(): void {
|
||||||
|
if (!draggedBlockId) return;
|
||||||
|
|
||||||
|
if (dropTargetIdx !== null) {
|
||||||
|
const sorted = [...getSortedBlocks()];
|
||||||
|
const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId);
|
||||||
|
if (fromIdx >= 0) {
|
||||||
|
const [moved] = sorted.splice(fromIdx, 1);
|
||||||
|
const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx;
|
||||||
|
sorted.splice(insertAt, 0, moved);
|
||||||
|
onReorder(sorted.map((b) => b.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draggedBlockId = null;
|
||||||
|
dropTargetIdx = null;
|
||||||
|
dragOffsetY = 0;
|
||||||
|
capturedEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get draggedBlockId() {
|
||||||
|
return draggedBlockId;
|
||||||
|
},
|
||||||
|
get dropTargetIdx() {
|
||||||
|
return dropTargetIdx;
|
||||||
|
},
|
||||||
|
get dragOffsetY() {
|
||||||
|
return dragOffsetY;
|
||||||
|
},
|
||||||
|
setListElement,
|
||||||
|
handleGripDown,
|
||||||
|
handlePointerMove,
|
||||||
|
handlePointerUp
|
||||||
|
};
|
||||||
|
}
|
||||||
41
frontend/src/lib/hooks/useFileLoader.svelte.ts
Normal file
41
frontend/src/lib/hooks/useFileLoader.svelte.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export function createFileLoader() {
|
||||||
|
let fileUrl = $state('');
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let fileError = $state('');
|
||||||
|
|
||||||
|
async function loadFile(url: string): Promise<void> {
|
||||||
|
isLoading = true;
|
||||||
|
fileError = '';
|
||||||
|
if (fileUrl) URL.revokeObjectURL(fileUrl);
|
||||||
|
fileUrl = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error('Failed to load file');
|
||||||
|
const blob = await response.blob();
|
||||||
|
fileUrl = URL.createObjectURL(blob);
|
||||||
|
} catch {
|
||||||
|
fileError = 'Vorschau konnte nicht geladen werden.';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy(): void {
|
||||||
|
if (fileUrl) URL.revokeObjectURL(fileUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get fileUrl() {
|
||||||
|
return fileUrl;
|
||||||
|
},
|
||||||
|
get isLoading() {
|
||||||
|
return isLoading;
|
||||||
|
},
|
||||||
|
get fileError() {
|
||||||
|
return fileError;
|
||||||
|
},
|
||||||
|
loadFile,
|
||||||
|
destroy
|
||||||
|
};
|
||||||
|
}
|
||||||
95
frontend/src/lib/hooks/useNotificationStream.svelte.ts
Normal file
95
frontend/src/lib/hooks/useNotificationStream.svelte.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { type NotificationItem, parseNotificationEvent } from '$lib/utils/notifications';
|
||||||
|
|
||||||
|
export type { NotificationItem };
|
||||||
|
|
||||||
|
export function createNotificationStream() {
|
||||||
|
let notifications = $state<NotificationItem[]>([]);
|
||||||
|
let unreadCount = $state(0);
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
|
||||||
|
async function fetchNotifications(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications?size=10');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
notifications = data.content ?? [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch notifications', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUnreadCount(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications/unread-count');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
unreadCount = data.count;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch unread count', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRead(notification: NotificationItem): Promise<void> {
|
||||||
|
if (!notification.read) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
|
||||||
|
notification.read = true;
|
||||||
|
unreadCount = Math.max(0, unreadCount - 1);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to mark notification as read', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch('/api/notifications/read-all', { method: 'POST' });
|
||||||
|
for (const n of notifications) {
|
||||||
|
n.read = true;
|
||||||
|
}
|
||||||
|
unreadCount = 0;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to mark all notifications as read', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(): void {
|
||||||
|
fetchUnreadCount();
|
||||||
|
eventSource = new EventSource('/api/notifications/stream');
|
||||||
|
eventSource.addEventListener('notification', (e) => {
|
||||||
|
const notification = parseNotificationEvent(e.data);
|
||||||
|
if (!notification) return;
|
||||||
|
notifications = [notification, ...notifications];
|
||||||
|
if (!notification.read) unreadCount += 1;
|
||||||
|
});
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
fetchUnreadCount();
|
||||||
|
};
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
// Close on error to avoid repeated reconnect noise
|
||||||
|
eventSource?.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy(): void {
|
||||||
|
eventSource?.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get notifications() {
|
||||||
|
return notifications;
|
||||||
|
},
|
||||||
|
get unreadCount() {
|
||||||
|
return unreadCount;
|
||||||
|
},
|
||||||
|
fetchNotifications,
|
||||||
|
fetchUnreadCount,
|
||||||
|
markRead,
|
||||||
|
markAllRead,
|
||||||
|
init,
|
||||||
|
destroy
|
||||||
|
};
|
||||||
|
}
|
||||||
203
frontend/src/lib/hooks/usePdfRenderer.svelte.ts
Normal file
203
frontend/src/lib/hooks/usePdfRenderer.svelte.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
|
||||||
|
|
||||||
|
export function createPdfRenderer() {
|
||||||
|
// Reactive state — exposed via getters
|
||||||
|
let currentPage = $state(1);
|
||||||
|
let totalPages = $state(0);
|
||||||
|
let scale = $state(1.5);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let pdfjsReady = $state(false);
|
||||||
|
|
||||||
|
// Internal mutable refs — NOT $state to avoid reactive loops
|
||||||
|
let pdfDoc: PDFDocumentProxy | null = null;
|
||||||
|
let canvasEl: HTMLCanvasElement | null = null;
|
||||||
|
let textLayerEl: HTMLDivElement | null = null;
|
||||||
|
let renderTask: RenderTask | null = null;
|
||||||
|
let textLayerInstance: { cancel: () => void } | null = null;
|
||||||
|
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
||||||
|
|
||||||
|
async function init(): Promise<void> {
|
||||||
|
const [lib, { default: workerUrl }] = await Promise.all([
|
||||||
|
import('pdfjs-dist'),
|
||||||
|
import('pdfjs-dist/build/pdf.worker.min.mjs?url')
|
||||||
|
]);
|
||||||
|
lib.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||||
|
pdfjsLib = lib;
|
||||||
|
pdfjsReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setElements(canvas: HTMLCanvasElement, textLayer: HTMLDivElement): void {
|
||||||
|
canvasEl = canvas;
|
||||||
|
textLayerEl = textLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDocument(src: string): Promise<void> {
|
||||||
|
if (!pdfjsLib) return;
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
pdfDoc = null;
|
||||||
|
currentPage = 1;
|
||||||
|
totalPages = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loadingTask = pdfjsLib.getDocument(src);
|
||||||
|
const doc = await loadingTask.promise;
|
||||||
|
pdfDoc = doc;
|
||||||
|
totalPages = doc.numPages;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load PDF';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderCurrentPage(): Promise<void> {
|
||||||
|
if (!pdfjsLib || !canvasEl || !textLayerEl || !pdfDoc) return;
|
||||||
|
|
||||||
|
if (renderTask) {
|
||||||
|
renderTask.cancel();
|
||||||
|
renderTask = null;
|
||||||
|
}
|
||||||
|
if (textLayerInstance) {
|
||||||
|
textLayerInstance.cancel();
|
||||||
|
textLayerInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let page;
|
||||||
|
try {
|
||||||
|
page = await pdfDoc.getPage(currentPage);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const viewport = page.getViewport({ scale: scale * dpr });
|
||||||
|
|
||||||
|
const canvas = canvasEl;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
canvas.style.width = `${viewport.width / dpr}px`;
|
||||||
|
canvas.style.height = `${viewport.height / dpr}px`;
|
||||||
|
|
||||||
|
const task = page.render({ canvas, canvasContext: ctx, viewport });
|
||||||
|
renderTask = task;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await task.promise;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (
|
||||||
|
typeof e === 'object' &&
|
||||||
|
e !== null &&
|
||||||
|
'name' in e &&
|
||||||
|
(e as { name: string }).name === 'RenderingCancelledException'
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderTask = null;
|
||||||
|
|
||||||
|
const textDiv = textLayerEl;
|
||||||
|
if (!textDiv) return;
|
||||||
|
textDiv.innerHTML = '';
|
||||||
|
textDiv.style.width = `${viewport.width / dpr}px`;
|
||||||
|
textDiv.style.height = `${viewport.height / dpr}px`;
|
||||||
|
|
||||||
|
const tl = new pdfjsLib.TextLayer({
|
||||||
|
textContentSource: page.streamTextContent(),
|
||||||
|
container: textDiv,
|
||||||
|
viewport
|
||||||
|
});
|
||||||
|
textLayerInstance = tl;
|
||||||
|
try {
|
||||||
|
await tl.render();
|
||||||
|
} catch {
|
||||||
|
// cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prerender(): Promise<void> {
|
||||||
|
if (!pdfDoc) return;
|
||||||
|
const neighbors = [currentPage - 1, currentPage + 1].filter(
|
||||||
|
(n) => n >= 1 && n <= (pdfDoc?.numPages ?? 0)
|
||||||
|
);
|
||||||
|
for (const n of neighbors) {
|
||||||
|
try {
|
||||||
|
await pdfDoc.getPage(n);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage(): void {
|
||||||
|
if (currentPage > 1) currentPage -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage(): void {
|
||||||
|
if (currentPage < totalPages) currentPage += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(n: number): void {
|
||||||
|
if (n >= 1 && n <= totalPages) currentPage = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn(): void {
|
||||||
|
scale += 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut(): void {
|
||||||
|
if (scale > 0.5) scale -= 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy(): void {
|
||||||
|
if (renderTask) {
|
||||||
|
renderTask.cancel();
|
||||||
|
renderTask = null;
|
||||||
|
}
|
||||||
|
if (textLayerInstance) {
|
||||||
|
textLayerInstance.cancel();
|
||||||
|
textLayerInstance = null;
|
||||||
|
}
|
||||||
|
pdfDoc?.destroy();
|
||||||
|
pdfDoc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get currentPage() {
|
||||||
|
return currentPage;
|
||||||
|
},
|
||||||
|
get totalPages() {
|
||||||
|
return totalPages;
|
||||||
|
},
|
||||||
|
get scale() {
|
||||||
|
return scale;
|
||||||
|
},
|
||||||
|
get loading() {
|
||||||
|
return loading;
|
||||||
|
},
|
||||||
|
get error() {
|
||||||
|
return error;
|
||||||
|
},
|
||||||
|
get isLoaded() {
|
||||||
|
return pdfDoc !== null;
|
||||||
|
},
|
||||||
|
get pdfjsReady() {
|
||||||
|
return pdfjsReady;
|
||||||
|
},
|
||||||
|
setElements,
|
||||||
|
init,
|
||||||
|
loadDocument,
|
||||||
|
renderCurrentPage,
|
||||||
|
prerender,
|
||||||
|
prevPage,
|
||||||
|
nextPage,
|
||||||
|
goToPage,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
destroy
|
||||||
|
};
|
||||||
|
}
|
||||||
46
frontend/src/lib/hooks/useUnsavedWarning.svelte.ts
Normal file
46
frontend/src/lib/hooks/useUnsavedWarning.svelte.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
|
|
||||||
|
export function createUnsavedWarning() {
|
||||||
|
let isDirty = $state(false);
|
||||||
|
let showUnsavedWarning = $state(false);
|
||||||
|
let discardTarget: string | null = $state(null);
|
||||||
|
|
||||||
|
beforeNavigate(({ cancel, to }) => {
|
||||||
|
if (isDirty) {
|
||||||
|
cancel();
|
||||||
|
showUnsavedWarning = true;
|
||||||
|
discardTarget = to?.url.href ?? null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
isDirty = true;
|
||||||
|
showUnsavedWarning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discard() {
|
||||||
|
isDirty = false;
|
||||||
|
showUnsavedWarning = false;
|
||||||
|
if (discardTarget) goto(discardTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOnSuccess() {
|
||||||
|
isDirty = false;
|
||||||
|
showUnsavedWarning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get isDirty() {
|
||||||
|
return isDirty;
|
||||||
|
},
|
||||||
|
get showUnsavedWarning() {
|
||||||
|
return showUnsavedWarning;
|
||||||
|
},
|
||||||
|
get discardTarget() {
|
||||||
|
return discardTarget;
|
||||||
|
},
|
||||||
|
markDirty,
|
||||||
|
discard,
|
||||||
|
clearOnSuccess
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,16 @@ export type CommentReply = {
|
|||||||
mentionDTOs?: MentionDTO[];
|
mentionDTOs?: MentionDTO[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FlatMessage = {
|
||||||
|
id: string;
|
||||||
|
authorId: string | null;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
mentionDTOs?: MentionDTO[];
|
||||||
|
};
|
||||||
|
|
||||||
export type Comment = {
|
export type Comment = {
|
||||||
id: string;
|
id: string;
|
||||||
authorId: string | null;
|
authorId: string | null;
|
||||||
|
|||||||
40
frontend/src/lib/utils/comment.spec.ts
Normal file
40
frontend/src/lib/utils/comment.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { extractQuote } from './comment';
|
||||||
|
|
||||||
|
describe('extractQuote', () => {
|
||||||
|
it('returns null quote and full body for plain text', () => {
|
||||||
|
const result = extractQuote('Hello world');
|
||||||
|
expect(result.quote).toBeNull();
|
||||||
|
expect(result.body).toBe('Hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts quote and body with double newline separator', () => {
|
||||||
|
const result = extractQuote('> "Some quoted text"\n\nReply body');
|
||||||
|
expect(result.quote).toBe('Some quoted text');
|
||||||
|
expect(result.body).toBe('Reply body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts quote and body with single newline separator', () => {
|
||||||
|
const result = extractQuote('> "Quote"\nBody');
|
||||||
|
expect(result.quote).toBe('Quote');
|
||||||
|
expect(result.body).toBe('Body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null quote when format does not match', () => {
|
||||||
|
const result = extractQuote('> Not a quote format');
|
||||||
|
expect(result.quote).toBeNull();
|
||||||
|
expect(result.body).toBe('> Not a quote format');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string', () => {
|
||||||
|
const result = extractQuote('');
|
||||||
|
expect(result.quote).toBeNull();
|
||||||
|
expect(result.body).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match when quotes are missing', () => {
|
||||||
|
const result = extractQuote('> just a blockquote\n\nbody');
|
||||||
|
expect(result.quote).toBeNull();
|
||||||
|
expect(result.body).toBe('> just a blockquote\n\nbody');
|
||||||
|
});
|
||||||
|
});
|
||||||
5
frontend/src/lib/utils/comment.ts
Normal file
5
frontend/src/lib/utils/comment.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export function extractQuote(content: string): { quote: string | null; body: string } {
|
||||||
|
const match = content.match(/^>\s*"(.+?)"\s*\n\n?([\s\S]*)$/);
|
||||||
|
if (match) return { quote: match[1], body: match[2] };
|
||||||
|
return { quote: null, body: content };
|
||||||
|
}
|
||||||
@@ -1,5 +1,25 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { formatGermanDateInput, isoToGerman, germanToIso } from './date';
|
import { formatDate, formatGermanDateInput, isoToGerman, germanToIso } from './date';
|
||||||
|
|
||||||
|
// ─── formatDate ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('defaults to long format when no format arg is passed', () => {
|
||||||
|
expect(formatDate('1943-12-24')).toBe('24. Dezember 1943');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats long date with German month name', () => {
|
||||||
|
expect(formatDate('1943-12-24', 'long')).toBe('24. Dezember 1943');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats short date as dd.mm.yyyy', () => {
|
||||||
|
expect(formatDate('1943-12-24', 'short')).toBe('24.12.1943');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not shift Dec 31 to Jan 1 (T12:00:00 UTC guard)', () => {
|
||||||
|
expect(formatDate('1943-12-31', 'short')).toBe('31.12.1943');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── isoToGerman ─────────────────────────────────────────────────────────────
|
// ─── isoToGerman ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Format an ISO date string (YYYY-MM-DD) for display.
|
* 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.
|
* Uses T12:00:00 to avoid UTC timezone off-by-one when converting to local time.
|
||||||
|
* Defaults to 'long' (e.g. "24. Dezember 1943"); pass 'short' for DD.MM.YYYY.
|
||||||
*/
|
*/
|
||||||
export function formatDate(isoDate: string): string {
|
export function formatDate(isoDate: string, format: 'short' | 'long' = 'long'): string {
|
||||||
|
const date = new Date(isoDate + 'T12:00:00');
|
||||||
|
if (format === 'short') {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
return new Intl.DateTimeFormat('de-DE', {
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
}).format(new Date(isoDate + 'T12:00:00'));
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
165
frontend/src/lib/utils/groupDocuments.spec.ts
Normal file
165
frontend/src/lib/utils/groupDocuments.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { groupDocuments } from './groupDocuments';
|
||||||
|
|
||||||
|
type Doc = {
|
||||||
|
id: string;
|
||||||
|
documentDate?: string | null;
|
||||||
|
sender?: { displayName: string } | null;
|
||||||
|
receivers?: { displayName: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const doc = (overrides: Partial<Doc> & { id: string }): Doc => ({
|
||||||
|
documentDate: null,
|
||||||
|
sender: null,
|
||||||
|
receivers: [],
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── DATE sort ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('groupDocuments — DATE sort', () => {
|
||||||
|
it('produces one group per distinct year', () => {
|
||||||
|
const docs = [
|
||||||
|
doc({ id: 'a', documentDate: '1923-04-12' }),
|
||||||
|
doc({ id: 'b', documentDate: '1938-01-01' }),
|
||||||
|
doc({ id: 'c', documentDate: '1965-08-03' })
|
||||||
|
];
|
||||||
|
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
|
||||||
|
expect(groups.map((g) => g.label)).toEqual(['1923', '1938', '1965']);
|
||||||
|
expect(groups.every((g) => g.documents.length === 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts multiple docs from the same year into one group', () => {
|
||||||
|
const docs = [
|
||||||
|
doc({ id: 'a', documentDate: '1938-03-01' }),
|
||||||
|
doc({ id: 'b', documentDate: '1938-11-15' })
|
||||||
|
];
|
||||||
|
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].label).toBe('1938');
|
||||||
|
expect(groups[0].documents).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places undated docs in the fallback group at the bottom', () => {
|
||||||
|
const docs = [
|
||||||
|
doc({ id: 'a', documentDate: '1938-01-01' }),
|
||||||
|
doc({ id: 'b', documentDate: null }),
|
||||||
|
doc({ id: 'c', documentDate: null })
|
||||||
|
];
|
||||||
|
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
expect(groups[0].label).toBe('1938');
|
||||||
|
expect(groups[1].label).toBe('Undatiert');
|
||||||
|
expect(groups[1].documents.map((d) => d.id)).toEqual(['b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns one group with fallback label when all docs are undated', () => {
|
||||||
|
const docs = [doc({ id: 'a' }), doc({ id: 'b' })];
|
||||||
|
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].label).toBe('Undatiert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns one group when all docs are from the same year', () => {
|
||||||
|
const docs = [
|
||||||
|
doc({ id: 'a', documentDate: '1938-01-01' }),
|
||||||
|
doc({ id: 'b', documentDate: '1938-06-15' })
|
||||||
|
];
|
||||||
|
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── SENDER sort ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('groupDocuments — SENDER sort', () => {
|
||||||
|
it('produces one group per distinct sender', () => {
|
||||||
|
const docs = [
|
||||||
|
doc({ id: 'a', sender: { displayName: 'Anna Müller' } }),
|
||||||
|
doc({ id: 'b', sender: { displayName: 'Karl Bauer' } }),
|
||||||
|
doc({ id: 'c', sender: { displayName: 'Anna Müller' } })
|
||||||
|
];
|
||||||
|
const groups = groupDocuments(docs, 'SENDER', 'Unbekannt');
|
||||||
|
expect(groups.map((g) => g.label)).toEqual(['Anna Müller', 'Karl Bauer']);
|
||||||
|
expect(groups[0].documents).toHaveLength(2);
|
||||||
|
expect(groups[1].documents).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places docs with no sender in the fallback group at the bottom', () => {
|
||||||
|
const docs = [
|
||||||
|
doc({ id: 'a', sender: { displayName: 'Anna Müller' } }),
|
||||||
|
doc({ id: 'b', sender: null })
|
||||||
|
];
|
||||||
|
const groups = groupDocuments(docs, 'SENDER', 'Unbekannt');
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
expect(groups[0].label).toBe('Anna Müller');
|
||||||
|
expect(groups[1].label).toBe('Unbekannt');
|
||||||
|
expect(groups[1].documents[0].id).toBe('b');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── RECEIVER sort ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('groupDocuments — RECEIVER sort', () => {
|
||||||
|
it('a doc with two receivers appears in both receiver groups', () => {
|
||||||
|
const docs = [
|
||||||
|
doc({
|
||||||
|
id: 'a',
|
||||||
|
receivers: [{ displayName: 'Anna' }, { displayName: 'Karl' }]
|
||||||
|
})
|
||||||
|
];
|
||||||
|
const groups = groupDocuments(docs, 'RECEIVER', 'Unbekannt');
|
||||||
|
expect(groups.map((g) => g.label)).toEqual(['Anna', 'Karl']);
|
||||||
|
expect(groups[0].documents[0].id).toBe('a');
|
||||||
|
expect(groups[1].documents[0].id).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places docs with no receivers in the fallback group at the bottom', () => {
|
||||||
|
const docs = [
|
||||||
|
doc({ id: 'a', receivers: [{ displayName: 'Anna' }] }),
|
||||||
|
doc({ id: 'b', receivers: [] })
|
||||||
|
];
|
||||||
|
const groups = groupDocuments(docs, 'RECEIVER', 'Unbekannt');
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
expect(groups[0].label).toBe('Anna');
|
||||||
|
expect(groups[1].label).toBe('Unbekannt');
|
||||||
|
expect(groups[1].documents[0].id).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('composite keys are unique: groupLabel + doc.id identifies each item', () => {
|
||||||
|
const docs = [
|
||||||
|
doc({ id: 'a', receivers: [{ displayName: 'Anna' }, { displayName: 'Karl' }] }),
|
||||||
|
doc({ id: 'b', receivers: [{ displayName: 'Anna' }] })
|
||||||
|
];
|
||||||
|
const groups = groupDocuments(docs, 'RECEIVER', 'Unbekannt');
|
||||||
|
const keys = groups.flatMap((g) => g.documents.map((d) => `${g.label}-${d.id}`));
|
||||||
|
const uniqueKeys = new Set(keys);
|
||||||
|
expect(uniqueKeys.size).toBe(keys.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Non-groupable sorts ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('groupDocuments — non-groupable sorts', () => {
|
||||||
|
it('TITLE sort returns one group containing all documents', () => {
|
||||||
|
const docs = [doc({ id: 'a' }), doc({ id: 'b' })];
|
||||||
|
const groups = groupDocuments(docs, 'TITLE', 'Undatiert');
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].documents).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UPLOAD_DATE sort returns one group containing all documents', () => {
|
||||||
|
const docs = [doc({ id: 'a' }), doc({ id: 'b' })];
|
||||||
|
const groups = groupDocuments(docs, 'UPLOAD_DATE', 'Undatiert');
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].documents).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Edge cases ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('groupDocuments — edge cases', () => {
|
||||||
|
it('returns empty array for an empty document list', () => {
|
||||||
|
expect(groupDocuments([], 'DATE', 'Undatiert')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
frontend/src/lib/utils/groupDocuments.ts
Normal file
56
frontend/src/lib/utils/groupDocuments.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
export type GroupableDoc = {
|
||||||
|
id: string;
|
||||||
|
documentDate?: string | null;
|
||||||
|
sender?: { displayName: string } | null;
|
||||||
|
receivers?: { displayName: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentGroup<T extends GroupableDoc> = {
|
||||||
|
label: string;
|
||||||
|
documents: T[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROUPABLE_SORTS = ['DATE', 'SENDER', 'RECEIVER'] as const;
|
||||||
|
type GroupableSort = (typeof GROUPABLE_SORTS)[number];
|
||||||
|
|
||||||
|
export function groupDocuments<T extends GroupableDoc>(
|
||||||
|
docs: T[],
|
||||||
|
sort: string,
|
||||||
|
fallbackLabel: string
|
||||||
|
): DocumentGroup<T>[] {
|
||||||
|
if (docs.length === 0) return [];
|
||||||
|
if (!GROUPABLE_SORTS.includes(sort as GroupableSort)) {
|
||||||
|
return [{ label: '', documents: [...docs] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupMap = new Map<string, T[]>();
|
||||||
|
const fallbackDocs: T[] = [];
|
||||||
|
|
||||||
|
for (const doc of docs) {
|
||||||
|
const keys = extractGroupKeys(doc, sort as GroupableSort);
|
||||||
|
if (keys.length === 0) {
|
||||||
|
fallbackDocs.push(doc);
|
||||||
|
} else {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!groupMap.has(key)) groupMap.set(key, []);
|
||||||
|
groupMap.get(key)!.push(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = [...groupMap.entries()].map(([label, documents]) => ({ label, documents }));
|
||||||
|
if (fallbackDocs.length > 0) groups.push({ label: fallbackLabel, documents: fallbackDocs });
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGroupKeys<T extends GroupableDoc>(doc: T, sort: GroupableSort): string[] {
|
||||||
|
if (sort === 'DATE') {
|
||||||
|
const year = doc.documentDate
|
||||||
|
? String(new Date(doc.documentDate + 'T12:00:00').getFullYear())
|
||||||
|
: null;
|
||||||
|
return year ? [year] : [];
|
||||||
|
}
|
||||||
|
if (sort === 'SENDER') return doc.sender ? [doc.sender.displayName] : [];
|
||||||
|
if (sort === 'RECEIVER') return (doc.receivers ?? []).map((r) => r.displayName);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -1,56 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { relativeTime, parseNotificationEvent } from '$lib/utils/notifications';
|
import { parseNotificationEvent } from '$lib/utils/notifications';
|
||||||
|
|
||||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
|
||||||
|
|
||||||
function msAgo(ms: number, now: Date): string {
|
|
||||||
return new Date(now.getTime() - ms).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('relativeTime', () => {
|
|
||||||
const now = new Date('2024-06-15T12:00:00.000Z');
|
|
||||||
|
|
||||||
it('should use minute bucket for timestamps under 60 seconds ago', () => {
|
|
||||||
const ts = msAgo(30_000, now);
|
|
||||||
expect(relativeTime(ts, now)).toBe(rtf.format(0, 'minute'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use minute bucket for exactly 59 minutes ago', () => {
|
|
||||||
const ts = msAgo(59 * 60_000, now);
|
|
||||||
expect(relativeTime(ts, now)).toBe(rtf.format(-59, 'minute'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use minute bucket for exactly 1 minute ago', () => {
|
|
||||||
const ts = msAgo(60_000, now);
|
|
||||||
expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'minute'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use hour bucket for exactly 1 hour ago', () => {
|
|
||||||
const ts = msAgo(60 * 60_000, now);
|
|
||||||
expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'hour'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use hour bucket for 23 hours ago', () => {
|
|
||||||
const ts = msAgo(23 * 60 * 60_000, now);
|
|
||||||
expect(relativeTime(ts, now)).toBe(rtf.format(-23, 'hour'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use day bucket for exactly 24 hours ago', () => {
|
|
||||||
const ts = msAgo(24 * 60 * 60_000, now);
|
|
||||||
expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'day'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use day bucket for 6 days ago', () => {
|
|
||||||
const ts = msAgo(6 * 24 * 60 * 60_000, now);
|
|
||||||
expect(relativeTime(ts, now)).toBe(rtf.format(-6, 'day'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default now to current time when omitted', () => {
|
|
||||||
// Just verify it returns a non-empty string — exact value depends on runtime clock
|
|
||||||
const ts = new Date(Date.now() - 5 * 60_000).toISOString();
|
|
||||||
expect(relativeTime(ts)).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseNotificationEvent', () => {
|
describe('parseNotificationEvent', () => {
|
||||||
const valid = {
|
const valid = {
|
||||||
|
|||||||
@@ -10,18 +10,7 @@ export type NotificationItem = {
|
|||||||
documentTitle: string | null;
|
documentTitle: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
export { relativeTime } from '$lib/utils/time';
|
||||||
|
|
||||||
export function relativeTime(isoString: string, now: Date = new Date()): string {
|
|
||||||
const diffMs = now.getTime() - new Date(isoString).getTime();
|
|
||||||
const diffMin = Math.floor(diffMs / 60_000);
|
|
||||||
if (diffMin < 1) return rtf.format(0, 'minute');
|
|
||||||
if (diffMin < 60) return rtf.format(-diffMin, 'minute');
|
|
||||||
const diffH = Math.floor(diffMin / 60);
|
|
||||||
if (diffH < 24) return rtf.format(-diffH, 'hour');
|
|
||||||
const diffD = Math.floor(diffH / 24);
|
|
||||||
return rtf.format(-diffD, 'day');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseNotificationEvent(raw: string): NotificationItem | null {
|
export function parseNotificationEvent(raw: string): NotificationItem | null {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
getInitials,
|
||||||
abbreviateName,
|
abbreviateName,
|
||||||
formatXsMeta,
|
formatXsMeta,
|
||||||
personAvatarColor,
|
personAvatarColor,
|
||||||
formatDate,
|
|
||||||
statusDotClass,
|
statusDotClass,
|
||||||
statusLabel
|
statusLabel
|
||||||
} from './personFormat';
|
} from './personFormat';
|
||||||
|
import { formatDate } from './date';
|
||||||
|
|
||||||
|
// ─── getInitials ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getInitials', () => {
|
||||||
|
it('returns first chars of first and last word uppercased', () => {
|
||||||
|
expect(getInitials('Marcel Raddatz')).toBe('MR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns single char for a single-word name', () => {
|
||||||
|
expect(getInitials('Raddatz')).toBe('R');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for an empty name', () => {
|
||||||
|
expect(getInitials('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits on whitespace only — hyphenated first word counts as one', () => {
|
||||||
|
expect(getInitials('Anna-Maria Raddatz')).toBe('AR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores extra whitespace between words', () => {
|
||||||
|
expect(getInitials(' Karl Raddatz ')).toBe('KR');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── abbreviateName ──────────────────────────────────────────────────────────
|
// ─── abbreviateName ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { formatDocumentStatus } from './documentStatusLabel';
|
import { formatDocumentStatus } from './documentStatusLabel';
|
||||||
|
import { formatDate } from './date';
|
||||||
|
|
||||||
type Person = { firstName?: string | null; lastName: string; displayName: string };
|
type Person = { firstName?: string | null; lastName: string; displayName: string };
|
||||||
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
|
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
|
||||||
@@ -18,9 +19,11 @@ function djb2(str: string): number {
|
|||||||
return Math.abs(hash);
|
return Math.abs(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitials(person: Person): string {
|
export function getInitials(name: string): string {
|
||||||
if (person.firstName) return `${person.firstName[0]}${person.lastName[0]}`.toUpperCase();
|
const words = name.trim().split(/\s+/).filter(Boolean);
|
||||||
return person.lastName.substring(0, 2).toUpperCase();
|
if (words.length === 0) return '';
|
||||||
|
if (words.length === 1) return words[0].charAt(0).toUpperCase();
|
||||||
|
return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function abbreviateName(person: Person): string {
|
export function abbreviateName(person: Person): string {
|
||||||
@@ -73,22 +76,6 @@ export function personAvatarColor(personId: string): string {
|
|||||||
return AVATAR_PALETTE[djb2(personId) % AVATAR_PALETTE.length];
|
return AVATAR_PALETTE[djb2(personId) % AVATAR_PALETTE.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(isoDate: string, format: 'short' | 'long'): string {
|
|
||||||
const date = new Date(isoDate + 'T12:00:00');
|
|
||||||
if (format === 'short') {
|
|
||||||
return new Intl.DateTimeFormat('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric'
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
return new Intl.DateTimeFormat('de-DE', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric'
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function statusDotClass(status: DocumentStatus): string {
|
export function statusDotClass(status: DocumentStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'PLACEHOLDER':
|
case 'PLACEHOLDER':
|
||||||
|
|||||||
52
frontend/src/lib/utils/time.spec.ts
Normal file
52
frontend/src/lib/utils/time.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
const { relativeTime } = await import('./time');
|
||||||
|
|
||||||
|
function msAgo(ms: number, now: Date): string {
|
||||||
|
return new Date(now.getTime() - ms).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('relativeTime', () => {
|
||||||
|
const now = new Date('2024-06-15T12:00:00.000Z');
|
||||||
|
|
||||||
|
it('returns "just now" for timestamps under 60 seconds ago', () => {
|
||||||
|
const ts = msAgo(30_000, now);
|
||||||
|
expect(relativeTime(ts, now)).toBe(m.comment_time_just_now());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 1-minute label for exactly 1 minute ago', () => {
|
||||||
|
const ts = msAgo(60_000, now);
|
||||||
|
expect(relativeTime(ts, now)).toBe(m.comment_time_minutes({ count: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 59-minute label for exactly 59 minutes ago', () => {
|
||||||
|
const ts = msAgo(59 * 60_000, now);
|
||||||
|
expect(relativeTime(ts, now)).toBe(m.comment_time_minutes({ count: 59 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 1-hour label for exactly 1 hour ago', () => {
|
||||||
|
const ts = msAgo(60 * 60_000, now);
|
||||||
|
expect(relativeTime(ts, now)).toBe(m.comment_time_hours({ count: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 23-hour label for 23 hours ago', () => {
|
||||||
|
const ts = msAgo(23 * 60 * 60_000, now);
|
||||||
|
expect(relativeTime(ts, now)).toBe(m.comment_time_hours({ count: 23 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 1-day label for exactly 24 hours ago', () => {
|
||||||
|
const ts = msAgo(24 * 60 * 60_000, now);
|
||||||
|
expect(relativeTime(ts, now)).toBe(m.comment_time_days({ count: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 6-day label for 6 days ago', () => {
|
||||||
|
const ts = msAgo(6 * 24 * 60 * 60_000, now);
|
||||||
|
expect(relativeTime(ts, now)).toBe(m.comment_time_days({ count: 6 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults now to current time when omitted', () => {
|
||||||
|
const ts = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||||
|
expect(relativeTime(ts)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
12
frontend/src/lib/utils/time.ts
Normal file
12
frontend/src/lib/utils/time.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
export function relativeTime(isoString: string, now: Date = new Date()): string {
|
||||||
|
const diff = now.getTime() - new Date(isoString).getTime();
|
||||||
|
const minutes = Math.floor(diff / 60_000);
|
||||||
|
if (minutes < 1) return m.comment_time_just_now();
|
||||||
|
if (minutes < 60) return m.comment_time_minutes({ count: minutes });
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return m.comment_time_hours({ count: hours });
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return m.comment_time_days({ count: days });
|
||||||
|
}
|
||||||
@@ -13,8 +13,18 @@ export async function load({ url, fetch }) {
|
|||||||
const senderId = url.searchParams.get('senderId') || '';
|
const senderId = url.searchParams.get('senderId') || '';
|
||||||
const receiverId = url.searchParams.get('receiverId') || '';
|
const receiverId = url.searchParams.get('receiverId') || '';
|
||||||
const tags = url.searchParams.getAll('tag');
|
const tags = url.searchParams.getAll('tag');
|
||||||
const sort = url.searchParams.get('sort') || 'DATE';
|
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE'] as const;
|
||||||
const dir = url.searchParams.get('dir') || 'desc';
|
type ValidSort = (typeof VALID_SORTS)[number];
|
||||||
|
const rawSort = url.searchParams.get('sort') ?? 'DATE';
|
||||||
|
const sort: ValidSort = (VALID_SORTS as readonly string[]).includes(rawSort)
|
||||||
|
? (rawSort as ValidSort)
|
||||||
|
: 'DATE';
|
||||||
|
const VALID_DIRS = ['asc', 'desc'] as const;
|
||||||
|
type ValidDir = (typeof VALID_DIRS)[number];
|
||||||
|
const rawDir = url.searchParams.get('dir') ?? 'desc';
|
||||||
|
const dir: ValidDir = (VALID_DIRS as readonly string[]).includes(rawDir)
|
||||||
|
? (rawDir as ValidDir)
|
||||||
|
: 'desc';
|
||||||
const tagQ = url.searchParams.get('tagQ') || '';
|
const tagQ = url.searchParams.get('tagQ') || '';
|
||||||
|
|
||||||
const isDashboard = !q && !from && !to && !senderId && !receiverId && !tags.length && !tagQ;
|
const isDashboard = !q && !from && !to && !senderId && !receiverId && !tags.length && !tagQ;
|
||||||
@@ -35,7 +45,7 @@ export async function load({ url, fetch }) {
|
|||||||
receiverId: receiverId || undefined,
|
receiverId: receiverId || undefined,
|
||||||
tag: tags.length ? tags : undefined,
|
tag: tags.length ? tags : undefined,
|
||||||
tagQ: tagQ || undefined,
|
tagQ: tagQ || undefined,
|
||||||
sort: sort as 'DATE' | 'TITLE' | 'SENDER' | 'RECEIVER' | 'UPLOAD_DATE',
|
sort,
|
||||||
dir: dir || undefined
|
dir: dir || undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
|
|||||||
error={data.error}
|
error={data.error}
|
||||||
total={data.total ?? 0}
|
total={data.total ?? 0}
|
||||||
q={q}
|
q={q}
|
||||||
|
sort={sort}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ let {
|
|||||||
sort?: string;
|
sort?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const fallbackLabel = $derived(sort === 'DATE' ? m.docs_group_undated() : m.docs_group_unknown());
|
const fallbackLabel = $derived(
|
||||||
|
(sort ?? 'DATE') === 'DATE' ? m.docs_group_undated() : m.docs_group_unknown()
|
||||||
|
);
|
||||||
const groupedDocuments = $derived.by(() =>
|
const groupedDocuments = $derived.by(() =>
|
||||||
groupDocuments(documents, sort ?? 'DATE', fallbackLabel)
|
groupDocuments(documents, sort ?? 'DATE', fallbackLabel)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
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 { page } from 'vitest/browser';
|
||||||
import DocumentList from './DocumentList.svelte';
|
import DocumentList from './DocumentList.svelte';
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
documents: [],
|
documents: [],
|
||||||
canWrite: false,
|
canWrite: false,
|
||||||
@@ -13,7 +15,14 @@ const baseProps = {
|
|||||||
q: ''
|
q: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeDoc = () => ({
|
type DocOverrides = {
|
||||||
|
id?: string;
|
||||||
|
documentDate?: string | null;
|
||||||
|
sender?: { firstName?: string | null; lastName: string; displayName: string } | null;
|
||||||
|
receivers?: { firstName?: string | null; lastName: string; displayName: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeDoc = (overrides: DocOverrides = {}) => ({
|
||||||
id: '1',
|
id: '1',
|
||||||
title: 'Testbrief',
|
title: 'Testbrief',
|
||||||
originalFilename: 'testbrief.pdf',
|
originalFilename: 'testbrief.pdf',
|
||||||
@@ -21,8 +30,9 @@ const makeDoc = () => ({
|
|||||||
documentDate: '2024-03-15',
|
documentDate: '2024-03-15',
|
||||||
location: null,
|
location: null,
|
||||||
sender: null,
|
sender: null,
|
||||||
receivers: [],
|
receivers: [] as { firstName?: string | null; lastName: string; displayName: string }[],
|
||||||
tags: []
|
tags: [],
|
||||||
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DocumentList – result count', () => {
|
describe('DocumentList – result count', () => {
|
||||||
@@ -49,3 +59,59 @@ describe('DocumentList – empty state with search term', () => {
|
|||||||
await expect.element(page.getByText(/"Urlaub"/)).toBeInTheDocument();
|
await expect.element(page.getByText(/"Urlaub"/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Group headers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DocumentList – group headers', () => {
|
||||||
|
it('renders group-divider elements when DATE sort spans multiple years', async () => {
|
||||||
|
const documents = [
|
||||||
|
makeDoc({ id: '1', documentDate: '1923-04-12' }),
|
||||||
|
makeDoc({ id: '2', documentDate: '1965-08-03' })
|
||||||
|
];
|
||||||
|
render(DocumentList, { ...baseProps, documents, total: 2, sort: 'DATE' });
|
||||||
|
await expect.element(page.getByTestId('group-divider').first()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render group-divider when DATE sort has only one distinct year', async () => {
|
||||||
|
const documents = [
|
||||||
|
makeDoc({ id: '1', documentDate: '1938-01-01' }),
|
||||||
|
makeDoc({ id: '2', documentDate: '1938-06-15' })
|
||||||
|
];
|
||||||
|
render(DocumentList, { ...baseProps, documents, total: 2, sort: 'DATE' });
|
||||||
|
await expect.element(page.getByTestId('group-divider')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render group-divider for TITLE sort', async () => {
|
||||||
|
const documents = [
|
||||||
|
makeDoc({ id: '1', documentDate: '1923-04-12' }),
|
||||||
|
makeDoc({ id: '2', documentDate: '1965-08-03' })
|
||||||
|
];
|
||||||
|
render(DocumentList, { ...baseProps, documents, total: 2, sort: 'TITLE' });
|
||||||
|
await expect.element(page.getByTestId('group-divider')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Undatiert fallback label when sort is undefined and doc has no date', async () => {
|
||||||
|
const documents = [
|
||||||
|
makeDoc({ id: '1', documentDate: '1938-01-01' }),
|
||||||
|
makeDoc({ id: '2', documentDate: null })
|
||||||
|
];
|
||||||
|
render(DocumentList, { ...baseProps, documents, total: 2 }); // sort omitted — defaults to DATE grouping
|
||||||
|
await expect.element(page.getByText(/UNDATIERT/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a doc with two receivers appears in both receiver groups', async () => {
|
||||||
|
const documents = [
|
||||||
|
makeDoc({
|
||||||
|
id: '1',
|
||||||
|
receivers: [
|
||||||
|
{ firstName: null, lastName: 'Müller', displayName: 'Anna Müller' },
|
||||||
|
{ firstName: null, lastName: 'Bauer', displayName: 'Karl Bauer' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
];
|
||||||
|
render(DocumentList, { ...baseProps, documents, total: 1, sort: 'RECEIVER' });
|
||||||
|
const links = page.getByRole('link', { name: /Testbrief/ });
|
||||||
|
await expect.element(links.first()).toBeInTheDocument();
|
||||||
|
await expect.element(links.nth(1)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { tick } from 'svelte';
|
|||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import EntityNavSection from './EntityNavSection.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
userCount,
|
userCount,
|
||||||
@@ -51,6 +52,76 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet usersIcon()}
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 flex-shrink-0 {isActive('users') ? 'text-brand-mint' : 'text-white/40'}"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet groupsIcon()}
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 flex-shrink-0 {isActive('groups') ? 'text-brand-mint' : 'text-white/40'}"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet tagsIcon()}
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/40'}"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
|
||||||
|
/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet systemIcon()}
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 flex-shrink-0 {isActive('system') ? 'text-brand-mint' : 'text-white/40'}"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||||
|
/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<svelte:document onkeydown={handleKeydown} />
|
<svelte:document onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -69,271 +140,53 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if canManageUsers}
|
{#if canManageUsers}
|
||||||
<!-- Tablet trigger button (md only, hidden at lg) -->
|
<EntityNavSection
|
||||||
<button
|
variant="sidebar"
|
||||||
data-flyout-trigger
|
|
||||||
type="button"
|
|
||||||
aria-label={m.admin_tab_users()}
|
|
||||||
title={m.admin_tab_users()}
|
|
||||||
onclick={openFlyout}
|
|
||||||
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
|
|
||||||
{isActive('users')
|
|
||||||
? 'border-brand-mint bg-white/10'
|
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-5 w-5 flex-shrink-0 {isActive('users') ? 'text-brand-mint' : 'text-white/40'}"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-[9px] font-bold {isActive('users') ? 'text-white/80' : 'text-white/35'}">
|
|
||||||
{userCount}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<!-- Desktop link (lg+) -->
|
|
||||||
<a
|
|
||||||
href="/admin/users"
|
href="/admin/users"
|
||||||
class="hidden flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors lg:flex
|
label={m.admin_tab_users()}
|
||||||
{isActive('users')
|
isActive={isActive('users')}
|
||||||
? 'border-brand-mint bg-white/10'
|
count={userCount}
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
onTabletTrigger={openFlyout}
|
||||||
aria-current={isActive('users') ? 'page' : undefined}
|
icon={usersIcon}
|
||||||
title={m.admin_tab_users()}
|
/>
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-5 w-5 flex-shrink-0 {isActive('users') ? 'text-brand-mint' : 'text-white/40'}"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/50'}">
|
|
||||||
{userCount}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
|
||||||
{isActive('users') ? 'text-white' : 'text-white/55'}"
|
|
||||||
>
|
|
||||||
{m.admin_tab_users()}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canManagePermissions}
|
{#if canManagePermissions}
|
||||||
<!-- Tablet trigger button (md only, hidden at lg) -->
|
<EntityNavSection
|
||||||
<button
|
variant="sidebar"
|
||||||
data-flyout-trigger
|
|
||||||
type="button"
|
|
||||||
aria-label={m.admin_tab_groups()}
|
|
||||||
title={m.admin_tab_groups()}
|
|
||||||
onclick={openFlyout}
|
|
||||||
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
|
|
||||||
{isActive('groups')
|
|
||||||
? 'border-brand-mint bg-white/10'
|
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-5 w-5 flex-shrink-0 {isActive('groups') ? 'text-brand-mint' : 'text-white/40'}"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-[9px] font-bold {isActive('groups') ? 'text-white/80' : 'text-white/35'}">
|
|
||||||
{groupCount}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<!-- Desktop link (lg+) -->
|
|
||||||
<a
|
|
||||||
href="/admin/groups"
|
href="/admin/groups"
|
||||||
class="hidden flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors lg:flex
|
label={m.admin_tab_groups()}
|
||||||
{isActive('groups')
|
isActive={isActive('groups')}
|
||||||
? 'border-brand-mint bg-white/10'
|
count={groupCount}
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
onTabletTrigger={openFlyout}
|
||||||
aria-current={isActive('groups') ? 'page' : undefined}
|
icon={groupsIcon}
|
||||||
title={m.admin_tab_groups()}
|
/>
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-5 w-5 flex-shrink-0 {isActive('groups') ? 'text-brand-mint' : 'text-white/40'}"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/50'}">
|
|
||||||
{groupCount}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
|
||||||
{isActive('groups') ? 'text-white' : 'text-white/55'}"
|
|
||||||
>
|
|
||||||
{m.admin_tab_groups()}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canManageTags}
|
{#if canManageTags}
|
||||||
<!-- Tablet trigger button (md only, hidden at lg) -->
|
<EntityNavSection
|
||||||
<button
|
variant="sidebar"
|
||||||
data-flyout-trigger
|
|
||||||
type="button"
|
|
||||||
aria-label={m.admin_tab_tags()}
|
|
||||||
title={m.admin_tab_tags()}
|
|
||||||
onclick={openFlyout}
|
|
||||||
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
|
|
||||||
{isActive('tags')
|
|
||||||
? 'border-brand-mint bg-white/10'
|
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/40'}"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
|
|
||||||
/>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
|
||||||
</svg>
|
|
||||||
<span class="text-[9px] font-bold {isActive('tags') ? 'text-white/80' : 'text-white/35'}">
|
|
||||||
{tagCount}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<!-- Desktop link (lg+) -->
|
|
||||||
<a
|
|
||||||
href="/admin/tags"
|
href="/admin/tags"
|
||||||
class="hidden flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors lg:flex
|
label={m.admin_tab_tags()}
|
||||||
{isActive('tags')
|
isActive={isActive('tags')}
|
||||||
? 'border-brand-mint bg-white/10'
|
count={tagCount}
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
onTabletTrigger={openFlyout}
|
||||||
aria-current={isActive('tags') ? 'page' : undefined}
|
icon={tagsIcon}
|
||||||
title={m.admin_tab_tags()}
|
/>
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/40'}"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
|
|
||||||
/>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
|
||||||
</svg>
|
|
||||||
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/50'}">
|
|
||||||
{tagCount}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
|
||||||
{isActive('tags') ? 'text-white' : 'text-white/55'}"
|
|
||||||
>
|
|
||||||
{m.admin_tab_tags()}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
{#if canRunMaintenance}
|
{#if canRunMaintenance}
|
||||||
<!-- Tablet trigger button (md only, hidden at lg) -->
|
<EntityNavSection
|
||||||
<button
|
variant="sidebar"
|
||||||
data-flyout-trigger
|
|
||||||
type="button"
|
|
||||||
aria-label={m.admin_tab_system()}
|
|
||||||
title={m.admin_tab_system()}
|
|
||||||
onclick={openFlyout}
|
|
||||||
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-t border-l-[3px] border-white/10 py-3 transition-colors lg:hidden
|
|
||||||
{isActive('system')
|
|
||||||
? 'border-brand-mint bg-white/10'
|
|
||||||
: 'border-l-transparent hover:bg-white/5'}"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-5 w-5 flex-shrink-0 {isActive('system') ? 'text-brand-mint' : 'text-white/40'}"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
|
||||||
/>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<!-- Desktop link (lg+) -->
|
|
||||||
<a
|
|
||||||
href="/admin/system"
|
href="/admin/system"
|
||||||
class="hidden flex-col items-start justify-center gap-0.5 border-t border-l-[3px] border-white/10 px-3.5 py-2.5 transition-colors lg:flex
|
label={m.admin_tab_system()}
|
||||||
{isActive('system')
|
isActive={isActive('system')}
|
||||||
? 'border-brand-mint bg-white/10'
|
topBorder={true}
|
||||||
: 'border-l-transparent hover:bg-white/5'}"
|
onTabletTrigger={openFlyout}
|
||||||
aria-current={isActive('system') ? 'page' : undefined}
|
icon={systemIcon}
|
||||||
title={m.admin_tab_system()}
|
/>
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-5 w-5 flex-shrink-0 {isActive('system') ? 'text-brand-mint' : 'text-white/40'}"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
|
||||||
/>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
|
||||||
{isActive('system') ? 'text-white' : 'text-white/55'}"
|
|
||||||
>
|
|
||||||
{m.admin_tab_system()}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -360,156 +213,53 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if canManageUsers}
|
{#if canManageUsers}
|
||||||
<a
|
<EntityNavSection
|
||||||
|
variant="flyout"
|
||||||
href="/admin/users"
|
href="/admin/users"
|
||||||
onclick={closeFlyout}
|
label={m.admin_tab_users()}
|
||||||
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
|
isActive={isActive('users')}
|
||||||
{isActive('users')
|
count={userCount}
|
||||||
? 'border-brand-mint bg-white/10'
|
onFlyoutClick={closeFlyout}
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
icon={usersIcon}
|
||||||
aria-current={isActive('users') ? 'page' : undefined}
|
/>
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-5 w-5 flex-shrink-0 {isActive('users') ? 'text-brand-mint' : 'text-white/40'}"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/50'}"
|
|
||||||
>
|
|
||||||
{userCount}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
|
||||||
{isActive('users') ? 'text-white' : 'text-white/55'}"
|
|
||||||
>
|
|
||||||
{m.admin_tab_users()}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canManagePermissions}
|
{#if canManagePermissions}
|
||||||
<a
|
<EntityNavSection
|
||||||
|
variant="flyout"
|
||||||
href="/admin/groups"
|
href="/admin/groups"
|
||||||
onclick={closeFlyout}
|
label={m.admin_tab_groups()}
|
||||||
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
|
isActive={isActive('groups')}
|
||||||
{isActive('groups')
|
count={groupCount}
|
||||||
? 'border-brand-mint bg-white/10'
|
onFlyoutClick={closeFlyout}
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
icon={groupsIcon}
|
||||||
aria-current={isActive('groups') ? 'page' : undefined}
|
/>
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-5 w-5 flex-shrink-0 {isActive('groups') ? 'text-brand-mint' : 'text-white/40'}"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/50'}"
|
|
||||||
>
|
|
||||||
{groupCount}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
|
||||||
{isActive('groups') ? 'text-white' : 'text-white/55'}"
|
|
||||||
>
|
|
||||||
{m.admin_tab_groups()}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canManageTags}
|
{#if canManageTags}
|
||||||
<a
|
<EntityNavSection
|
||||||
|
variant="flyout"
|
||||||
href="/admin/tags"
|
href="/admin/tags"
|
||||||
onclick={closeFlyout}
|
label={m.admin_tab_tags()}
|
||||||
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
|
isActive={isActive('tags')}
|
||||||
{isActive('tags')
|
count={tagCount}
|
||||||
? 'border-brand-mint bg-white/10'
|
onFlyoutClick={closeFlyout}
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
icon={tagsIcon}
|
||||||
aria-current={isActive('tags') ? 'page' : undefined}
|
/>
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/40'}"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
|
|
||||||
/>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
|
||||||
</svg>
|
|
||||||
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/50'}">
|
|
||||||
{tagCount}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
|
||||||
{isActive('tags') ? 'text-white' : 'text-white/55'}"
|
|
||||||
>
|
|
||||||
{m.admin_tab_tags()}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
{#if canRunMaintenance}
|
{#if canRunMaintenance}
|
||||||
<a
|
<EntityNavSection
|
||||||
|
variant="flyout"
|
||||||
href="/admin/system"
|
href="/admin/system"
|
||||||
onclick={closeFlyout}
|
label={m.admin_tab_system()}
|
||||||
class="flex flex-col items-start justify-center gap-0.5 border-t border-l-[3px] border-white/10 px-3.5 py-2.5 transition-colors
|
isActive={isActive('system')}
|
||||||
{isActive('system')
|
topBorder={true}
|
||||||
? 'border-brand-mint bg-white/10'
|
onFlyoutClick={closeFlyout}
|
||||||
: 'border-l-transparent hover:bg-white/5'}"
|
icon={systemIcon}
|
||||||
aria-current={isActive('system') ? 'page' : undefined}
|
/>
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-5 w-5 flex-shrink-0 {isActive('system') ? 'text-brand-mint' : 'text-white/40'}"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
|
||||||
{isActive('system') ? 'text-white' : 'text-white/55'}"
|
|
||||||
>
|
|
||||||
{m.admin_tab_system()}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
90
frontend/src/routes/admin/EntityNavSection.svelte
Normal file
90
frontend/src/routes/admin/EntityNavSection.svelte
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
count?: number;
|
||||||
|
topBorder?: boolean;
|
||||||
|
icon: Snippet;
|
||||||
|
variant?: 'sidebar' | 'flyout';
|
||||||
|
onTabletTrigger?: (event: MouseEvent) => void;
|
||||||
|
onFlyoutClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
isActive,
|
||||||
|
count,
|
||||||
|
topBorder = false,
|
||||||
|
icon,
|
||||||
|
variant = 'sidebar',
|
||||||
|
onTabletTrigger,
|
||||||
|
onFlyoutClick
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if variant === 'sidebar'}
|
||||||
|
<!-- Tablet button (visible at md, hidden at lg) -->
|
||||||
|
<button
|
||||||
|
data-flyout-trigger
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onclick={onTabletTrigger}
|
||||||
|
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
|
||||||
|
{topBorder ? 'border-t border-white/10' : ''}
|
||||||
|
{isActive ? 'border-brand-mint bg-white/10' : (topBorder ? 'border-l-transparent hover:bg-white/5' : 'border-transparent hover:bg-white/5')}"
|
||||||
|
>
|
||||||
|
{@render icon()}
|
||||||
|
{#if count !== undefined}
|
||||||
|
<span class="text-[11px] font-bold {isActive ? 'text-white/80' : 'text-white/35'}"
|
||||||
|
>{count}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Desktop link (hidden at md, visible at lg) -->
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
class="hidden flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors lg:flex
|
||||||
|
{topBorder ? 'border-t border-white/10' : ''}
|
||||||
|
{isActive ? 'border-brand-mint bg-white/10' : (topBorder ? 'border-l-transparent hover:bg-white/5' : 'border-transparent hover:bg-white/5')}"
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{@render icon()}
|
||||||
|
{#if count !== undefined}
|
||||||
|
<span class="text-[13px] font-black {isActive ? 'text-white/65' : 'text-white/50'}"
|
||||||
|
>{count}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-extrabold tracking-[0.5px] uppercase {isActive ? 'text-white' : 'text-white/55'}"
|
||||||
|
>{label}</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<!-- Flyout link -->
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
onclick={onFlyoutClick}
|
||||||
|
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
|
||||||
|
{topBorder ? 'border-t border-white/10' : ''}
|
||||||
|
{isActive ? 'border-brand-mint bg-white/10' : (topBorder ? 'border-l-transparent hover:bg-white/5' : 'border-transparent hover:bg-white/5')}"
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{@render icon()}
|
||||||
|
{#if count !== undefined}
|
||||||
|
<span class="text-[13px] font-black {isActive ? 'text-white/65' : 'text-white/50'}"
|
||||||
|
>{count}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-extrabold tracking-[0.5px] uppercase {isActive ? 'text-white' : 'text-white/55'}"
|
||||||
|
>{label}</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
140
frontend/src/routes/admin/EntityNavSection.svelte.spec.ts
Normal file
140
frontend/src/routes/admin/EntityNavSection.svelte.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { afterEach, describe, it, expect } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import { createRawSnippet } from 'svelte';
|
||||||
|
import EntityNavSection from './EntityNavSection.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const testIcon = createRawSnippet(() => ({
|
||||||
|
render: () => `<svg aria-label="test-icon" aria-hidden="true"></svg>`,
|
||||||
|
setup: () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
href: '/admin/users',
|
||||||
|
label: 'Benutzer',
|
||||||
|
icon: testIcon
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('EntityNavSection — sidebar variant (default)', () => {
|
||||||
|
it('tablet button has border-brand-mint class when isActive=true', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: true });
|
||||||
|
const button = document.querySelector('button[data-flyout-trigger]')!;
|
||||||
|
expect(button.className).toContain('border-brand-mint');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tablet button has border-transparent class when isActive=false', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: false });
|
||||||
|
const button = document.querySelector('button[data-flyout-trigger]')!;
|
||||||
|
expect(button.className).toContain('border-transparent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders count span when count is provided', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: false, count: 42 });
|
||||||
|
// Sidebar renders two elements (tablet button + desktop link), each with a count span
|
||||||
|
const countSpans = document.querySelectorAll('span');
|
||||||
|
const countTexts = Array.from(countSpans).filter((s) => s.textContent?.trim() === '42');
|
||||||
|
expect(countTexts.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render count span when count is undefined', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: false });
|
||||||
|
// No numeric count element — the label text is present but no count span
|
||||||
|
const spans = document.querySelectorAll('button[data-flyout-trigger] span');
|
||||||
|
expect(spans.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('desktop link has hidden and lg:flex classes', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: false });
|
||||||
|
const link = document.querySelector('a[href="/admin/users"]')!;
|
||||||
|
expect(link.className).toContain('hidden');
|
||||||
|
expect(link.className).toContain('lg:flex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('desktop link has aria-current=page when isActive=true', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: true });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('link', { name: 'Benutzer' }))
|
||||||
|
.toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('desktop link does not have aria-current when isActive=false', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: false });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('link', { name: 'Benutzer' }))
|
||||||
|
.not.toHaveAttribute('aria-current');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the icon in the tablet button', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: false });
|
||||||
|
const button = document.querySelector('button[data-flyout-trigger]')!;
|
||||||
|
expect(button.querySelector('svg')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders count in desktop link when count is provided', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: false, count: 7 });
|
||||||
|
const link = document.querySelector('a[href="/admin/users"]')!;
|
||||||
|
expect(link.textContent).toContain('7');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EntityNavSection — topBorder prop', () => {
|
||||||
|
it('tablet button has border-l-transparent (not border-transparent) when topBorder=true and inactive', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: false, topBorder: true });
|
||||||
|
const button = document.querySelector('button[data-flyout-trigger]')!;
|
||||||
|
expect(button.className).toContain('border-l-transparent');
|
||||||
|
expect(button.className).not.toContain('border-transparent hover:bg-white/5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tablet button still has border-brand-mint when topBorder=true and isActive=true', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: true, topBorder: true });
|
||||||
|
const button = document.querySelector('button[data-flyout-trigger]')!;
|
||||||
|
expect(button.className).toContain('border-brand-mint');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EntityNavSection — flyout variant', () => {
|
||||||
|
it('renders a single anchor element (no button) in flyout variant', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: false, variant: 'flyout' });
|
||||||
|
expect(document.querySelector('button[data-flyout-trigger]')).toBeNull();
|
||||||
|
expect(document.querySelector('a[href="/admin/users"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flyout link has border-brand-mint class when isActive=true', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: true, variant: 'flyout' });
|
||||||
|
const link = document.querySelector('a[href="/admin/users"]')!;
|
||||||
|
expect(link.className).toContain('border-brand-mint');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flyout link has border-transparent class when isActive=false', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: false, variant: 'flyout' });
|
||||||
|
const link = document.querySelector('a[href="/admin/users"]')!;
|
||||||
|
expect(link.className).toContain('border-transparent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flyout link shows count when count=42', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: false, variant: 'flyout', count: 42 });
|
||||||
|
await expect.element(page.getByText('42')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flyout link has aria-current=page when isActive=true', async () => {
|
||||||
|
render(EntityNavSection, { ...baseProps, isActive: true, variant: 'flyout' });
|
||||||
|
const link = document.querySelector('a[href="/admin/users"]')!;
|
||||||
|
expect(link.getAttribute('aria-current')).toBe('page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flyout link calls onFlyoutClick when clicked', async () => {
|
||||||
|
let called = false;
|
||||||
|
render(EntityNavSection, {
|
||||||
|
...baseProps,
|
||||||
|
isActive: false,
|
||||||
|
variant: 'flyout',
|
||||||
|
onFlyoutClick: () => {
|
||||||
|
called = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelector<HTMLAnchorElement>('a[href="/admin/users"]')!.click();
|
||||||
|
expect(called).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
|
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
|
||||||
|
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const { confirm } = getConfirmService();
|
const { confirm } = getConfirmService();
|
||||||
|
const unsaved = createUnsavedWarning();
|
||||||
|
|
||||||
let isDirty = $state(false);
|
|
||||||
let showUnsavedWarning = $state(false);
|
|
||||||
let discardTarget: string | null = $state(null);
|
|
||||||
let deleteFormEl = $state<HTMLFormElement | null>(null);
|
let deleteFormEl = $state<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
@@ -21,19 +20,8 @@ async function handleDelete() {
|
|||||||
if (confirmed) deleteFormEl!.requestSubmit();
|
if (confirmed) deleteFormEl!.requestSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to }) => {
|
|
||||||
if (isDirty) {
|
|
||||||
cancel();
|
|
||||||
showUnsavedWarning = true;
|
|
||||||
discardTarget = to?.url.href ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.success) {
|
if (form?.success) unsaved.clearOnSuccess();
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const STANDARD_PERMISSIONS = $derived([
|
const STANDARD_PERMISSIONS = $derived([
|
||||||
@@ -84,23 +72,8 @@ const ADMIN_PERMISSIONS = $derived([
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
{#if showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<div
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
<span>{m.admin_unsaved_warning()}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
if (discardTarget) goto(discardTarget);
|
|
||||||
}}
|
|
||||||
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
{m.person_discard_changes()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.success}
|
{#if form?.success}
|
||||||
<div
|
<div
|
||||||
@@ -122,10 +95,7 @@ const ADMIN_PERMISSIONS = $derived([
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="?/update"
|
action="?/update"
|
||||||
use:enhance
|
use:enhance
|
||||||
oninput={() => {
|
oninput={unsaved.markDirty}
|
||||||
isDirty = true;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<!-- Group name card -->
|
<!-- Group name card -->
|
||||||
<div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm">
|
<div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm">
|
||||||
|
|||||||
@@ -1,30 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
|
||||||
|
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
let deleteConfirmName = $state('');
|
let deleteConfirmName = $state('');
|
||||||
const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
|
const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
|
||||||
|
|
||||||
let isDirty = $state(false);
|
const unsaved = createUnsavedWarning();
|
||||||
let showUnsavedWarning = $state(false);
|
|
||||||
let discardTarget: string | null = $state(null);
|
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to }) => {
|
|
||||||
if (isDirty) {
|
|
||||||
cancel();
|
|
||||||
showUnsavedWarning = true;
|
|
||||||
discardTarget = to?.url.href ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.success) {
|
if (form?.success) unsaved.clearOnSuccess();
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -53,23 +41,8 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
{#if showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<div
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
<span>{m.admin_unsaved_warning()}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
if (discardTarget) goto(discardTarget);
|
|
||||||
}}
|
|
||||||
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
{m.person_discard_changes()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.success}
|
{#if form?.success}
|
||||||
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
||||||
@@ -88,10 +61,7 @@ $effect(() => {
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="?/update"
|
action="?/update"
|
||||||
use:enhance
|
use:enhance
|
||||||
oninput={() => {
|
oninput={unsaved.markDirty}
|
||||||
isDirty = true;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}}
|
|
||||||
class="mb-5"
|
class="mb-5"
|
||||||
>
|
>
|
||||||
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
import UserProfileSection from '$lib/components/user/UserProfileSection.svelte';
|
import UserProfileSection from '$lib/components/user/UserProfileSection.svelte';
|
||||||
import UserGroupsSection from '$lib/components/user/UserGroupsSection.svelte';
|
import UserGroupsSection from '$lib/components/user/UserGroupsSection.svelte';
|
||||||
import UserPasswordSection from '$lib/components/user/UserPasswordSection.svelte';
|
import UserPasswordSection from '$lib/components/user/UserPasswordSection.svelte';
|
||||||
|
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
|
||||||
|
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const { confirm } = getConfirmService();
|
const { confirm } = getConfirmService();
|
||||||
|
const unsaved = createUnsavedWarning();
|
||||||
|
|
||||||
const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string }) => g.id) ?? []);
|
const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string }) => g.id) ?? []);
|
||||||
|
|
||||||
let isDirty = $state(false);
|
|
||||||
let showUnsavedWarning = $state(false);
|
|
||||||
let discardTarget: string | null = $state(null);
|
|
||||||
let deleteFormEl = $state<HTMLFormElement | null>(null);
|
let deleteFormEl = $state<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
@@ -26,19 +25,8 @@ async function handleDelete() {
|
|||||||
if (confirmed) deleteFormEl!.requestSubmit();
|
if (confirmed) deleteFormEl!.requestSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to }) => {
|
|
||||||
if (isDirty) {
|
|
||||||
cancel();
|
|
||||||
showUnsavedWarning = true;
|
|
||||||
discardTarget = to?.url.href ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.success) {
|
if (form?.success) unsaved.clearOnSuccess();
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -76,23 +64,8 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
{#if showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<div
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
<span>{m.admin_unsaved_warning()}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
if (discardTarget) goto(discardTarget);
|
|
||||||
}}
|
|
||||||
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
{m.person_discard_changes()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.success}
|
{#if form?.success}
|
||||||
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
||||||
@@ -109,10 +82,7 @@ $effect(() => {
|
|||||||
id="edit-user-form"
|
id="edit-user-form"
|
||||||
method="POST"
|
method="POST"
|
||||||
use:enhance
|
use:enhance
|
||||||
oninput={() => {
|
oninput={unsaved.markDirty}
|
||||||
isDirty = true;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}}
|
|
||||||
class="space-y-5"
|
class="space-y-5"
|
||||||
>
|
>
|
||||||
<!-- Profile card -->
|
<!-- Profile card -->
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
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 api = createApiClient(fetch);
|
|
||||||
|
|
||||||
let documents: components['schemas']['Document'][] = [];
|
|
||||||
let senderName = '';
|
|
||||||
let receiverName = '';
|
|
||||||
|
|
||||||
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) {
|
|
||||||
requests.push(
|
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
|
|
||||||
const p = data as { displayName: string } | undefined;
|
|
||||||
if (p) senderName = p.displayName;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (receiverId) {
|
|
||||||
requests.push(
|
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
|
|
||||||
const p = data as { displayName: string } | undefined;
|
|
||||||
if (p) receiverName = p.displayName;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(requests);
|
|
||||||
|
|
||||||
return {
|
|
||||||
documents,
|
|
||||||
initialValues: { senderName, receiverName },
|
|
||||||
filters: { senderId, receiverId, from, to, dir }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import ConversationFilterBar from './ConversationFilterBar.svelte';
|
|
||||||
import ConversationTimeline from './ConversationTimeline.svelte';
|
|
||||||
|
|
||||||
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));
|
|
||||||
|
|
||||||
// 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 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();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-5xl px-4 py-10">
|
|
||||||
<!-- Page Header -->
|
|
||||||
<div class="mb-8 border-b border-ink/10 pb-4">
|
|
||||||
<h1 class="font-serif text-3xl font-medium text-ink">{m.conv_heading()}</h1>
|
|
||||||
<p class="mt-2 font-sans text-sm text-ink-2">
|
|
||||||
{m.conv_subtitle()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConversationFilterBar
|
|
||||||
bind:senderId={senderId}
|
|
||||||
bind:receiverId={receiverId}
|
|
||||||
bind:fromDate={fromDate}
|
|
||||||
bind:toDate={toDate}
|
|
||||||
bind:sortDir={sortDir}
|
|
||||||
initialSenderName={data.initialValues.senderName}
|
|
||||||
initialReceiverName={data.initialValues.receiverName}
|
|
||||||
onapplyFilters={applyFilters}
|
|
||||||
ontoggleSort={toggleSort}
|
|
||||||
onswapPersons={swapPersons}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- RESULTS LIST SECTION -->
|
|
||||||
{#if !senderId || !receiverId}
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface py-24 text-center"
|
|
||||||
>
|
|
||||||
<div class="mb-4 rounded-full bg-muted p-4 text-ink">
|
|
||||||
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
||||||
><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<p class="font-serif text-lg text-ink">{m.conv_empty_heading()}</p>
|
|
||||||
<p class="mt-1 font-sans text-sm text-ink-2">{m.conv_empty_text()}</p>
|
|
||||||
</div>
|
|
||||||
{:else if data.documents.length === 0}
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center justify-center rounded-sm border border-line bg-surface py-24 text-center shadow-sm"
|
|
||||||
>
|
|
||||||
<p class="font-serif text-ink">{m.conv_no_results_heading()}</p>
|
|
||||||
<p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<ConversationTimeline
|
|
||||||
documents={data.documents}
|
|
||||||
senderId={senderId}
|
|
||||||
receiverId={receiverId}
|
|
||||||
canWrite={data.canWrite}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
|
|
||||||
let {
|
|
||||||
senderId = $bindable(''),
|
|
||||||
receiverId = $bindable(''),
|
|
||||||
fromDate = $bindable(''),
|
|
||||||
toDate = $bindable(''),
|
|
||||||
sortDir = $bindable('DESC'),
|
|
||||||
initialSenderName = '',
|
|
||||||
initialReceiverName = '',
|
|
||||||
onapplyFilters,
|
|
||||||
ontoggleSort,
|
|
||||||
onswapPersons
|
|
||||||
}: {
|
|
||||||
senderId?: string;
|
|
||||||
receiverId?: string;
|
|
||||||
fromDate?: string;
|
|
||||||
toDate?: string;
|
|
||||||
sortDir?: string;
|
|
||||||
initialSenderName?: string;
|
|
||||||
initialReceiverName?: string;
|
|
||||||
onapplyFilters: () => void;
|
|
||||||
ontoggleSort: () => void;
|
|
||||||
onswapPersons: () => void;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="relative z-20 mb-10 border border-line bg-surface 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 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
|
||||||
>
|
|
||||||
<PersonTypeahead
|
|
||||||
name="senderId"
|
|
||||||
label={m.conv_label_person_a()}
|
|
||||||
bind:value={senderId}
|
|
||||||
initialName={initialSenderName}
|
|
||||||
restrictToCorrespondentsOf={receiverId || undefined}
|
|
||||||
onchange={() => onapplyFilters()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Swap button -->
|
|
||||||
<div class="{senderId && receiverId ? 'flex' : 'hidden md:flex'} items-end">
|
|
||||||
<button
|
|
||||||
data-testid="conv-swap-btn"
|
|
||||||
onclick={onswapPersons}
|
|
||||||
class="flex w-full items-center justify-center gap-2 border border-line px-3 py-2.5 text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg 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 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
|
||||||
>
|
|
||||||
<PersonTypeahead
|
|
||||||
name="receiverId"
|
|
||||||
label={m.conv_label_person_b()}
|
|
||||||
bind:value={receiverId}
|
|
||||||
initialName={initialReceiverName}
|
|
||||||
restrictToCorrespondentsOf={senderId || undefined}
|
|
||||||
onchange={() => onapplyFilters()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
|
|
||||||
<!-- Date From -->
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="dateFrom"
|
|
||||||
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
|
||||||
>{m.conv_label_from()}</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="dateFrom"
|
|
||||||
type="date"
|
|
||||||
bind:value={fromDate}
|
|
||||||
onchange={() => onapplyFilters()}
|
|
||||||
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Date To -->
|
|
||||||
<div>
|
|
||||||
<label for="dateTo" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
|
||||||
>{m.conv_label_to()}</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="dateTo"
|
|
||||||
type="date"
|
|
||||||
bind:value={toDate}
|
|
||||||
onchange={() => onapplyFilters()}
|
|
||||||
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sort Toggle -->
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onclick={ontoggleSort}
|
|
||||||
class="flex h-[42px] w-full items-center justify-center border border-line text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
|
|
||||||
>
|
|
||||||
<span class="mr-2">{m.conv_sort_label()}</span>
|
|
||||||
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
|
|
||||||
<svg
|
|
||||||
class="ml-2 h-4 w-4 transform {sortDir === 'ASC'
|
|
||||||
? 'rotate-180'
|
|
||||||
: ''} transition-transform"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import { formatDate } from '$lib/utils/date';
|
|
||||||
|
|
||||||
let {
|
|
||||||
documents,
|
|
||||||
senderId,
|
|
||||||
receiverId,
|
|
||||||
canWrite
|
|
||||||
}: {
|
|
||||||
documents: {
|
|
||||||
id: string;
|
|
||||||
title?: string;
|
|
||||||
originalFilename: string;
|
|
||||||
documentDate?: string;
|
|
||||||
location?: string;
|
|
||||||
status: string;
|
|
||||||
sender?: {
|
|
||||||
id: string;
|
|
||||||
firstName?: string | null;
|
|
||||||
lastName: string;
|
|
||||||
displayName: string;
|
|
||||||
} | null;
|
|
||||||
}[];
|
|
||||||
senderId: string;
|
|
||||||
receiverId: string;
|
|
||||||
canWrite: boolean;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const documentYears = $derived(
|
|
||||||
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);
|
|
||||||
|
|
||||||
const enrichedDocuments = $derived(
|
|
||||||
documents.map((doc, i) => {
|
|
||||||
const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null;
|
|
||||||
const prevYear =
|
|
||||||
i > 0 && documents[i - 1].documentDate
|
|
||||||
? new Date(documents[i - 1].documentDate!).getFullYear()
|
|
||||||
: null;
|
|
||||||
return { doc, year, showYearDivider: year !== null && year !== prevYear };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- 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-ink/70">
|
|
||||||
{m.conv_summary({ count: documents.length, yearFrom, yearTo })}
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
|
|
||||||
{documents.length}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{#if 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-ink-2 transition-colors hover:text-ink"
|
|
||||||
>
|
|
||||||
<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="relative overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
|
||||||
<!-- Decoration: Central Timeline Line -->
|
|
||||||
<div
|
|
||||||
class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-muted md:block"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<div class="p-6 md:p-8">
|
|
||||||
<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-line"></div>
|
|
||||||
<span class="mx-4 font-sans text-xs font-bold tracking-widest text-ink/40 uppercase"
|
|
||||||
>{year}</span
|
|
||||||
>
|
|
||||||
<div class="flex-grow border-t border-line"></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%] gap-3 md:max-w-[70%] {isRight
|
|
||||||
? 'flex-row-reverse'
|
|
||||||
: 'flex-row'}"
|
|
||||||
>
|
|
||||||
<!-- AVATAR -->
|
|
||||||
<div class="mt-auto mb-1 hidden flex-shrink-0 sm:block">
|
|
||||||
<div
|
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-full border font-serif text-xs shadow-sm
|
|
||||||
{isRight
|
|
||||||
? 'border-primary bg-primary text-primary-fg'
|
|
||||||
: 'border-line bg-surface text-ink'}"
|
|
||||||
>
|
|
||||||
{#if doc.sender}
|
|
||||||
{doc.sender.firstName ? doc.sender.firstName[0] : doc.sender.lastName[0]}{doc.sender.lastName[0]}
|
|
||||||
{:else}
|
|
||||||
?
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- BUBBLE CARD -->
|
|
||||||
<a
|
|
||||||
href="/documents/{doc.id}"
|
|
||||||
class="group block transform rounded border p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md
|
|
||||||
{isRight
|
|
||||||
? 'rounded-br-none border-primary bg-primary text-primary-fg'
|
|
||||||
: 'rounded-bl-none border-line bg-muted/50 text-ink'}"
|
|
||||||
>
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-2 flex items-start justify-between gap-4">
|
|
||||||
<h3
|
|
||||||
class="font-serif text-sm leading-snug font-medium {isRight
|
|
||||||
? 'text-primary-fg'
|
|
||||||
: 'text-ink'}"
|
|
||||||
>
|
|
||||||
{doc.title || doc.originalFilename}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<!-- Status Dot -->
|
|
||||||
<span
|
|
||||||
class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full
|
|
||||||
{doc.status === 'UPLOADED' ? 'bg-accent' : 'bg-yellow-400'}"
|
|
||||||
title={doc.status}
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Metadata -->
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap gap-3 font-sans text-[10px] tracking-wider uppercase opacity-80 {isRight
|
|
||||||
? 'text-primary-fg/70'
|
|
||||||
: 'text-ink-2'}"
|
|
||||||
>
|
|
||||||
<span class="flex items-center">
|
|
||||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
|
||||||
</span>
|
|
||||||
{#if doc.location}
|
|
||||||
<span class="flex items-center">
|
|
||||||
• {doc.location}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
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,
|
|
||||||
canAnnotate: false,
|
|
||||||
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 empty-state heading when no persons are selected', async () => {
|
|
||||||
render(Page, { data: baseData });
|
|
||||||
await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
@@ -9,6 +9,7 @@ import TranscriptionPanelHeader from '$lib/components/TranscriptionPanelHeader.s
|
|||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
|
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
|
||||||
|
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -18,38 +19,15 @@ const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
|||||||
|
|
||||||
// ── File loading ──────────────────────────────────────────────────────────────
|
// ── File loading ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let fileUrl = $state('');
|
const fileLoader = createFileLoader();
|
||||||
let isLoading = $state(false);
|
|
||||||
let fileError = $state('');
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (doc?.id && doc?.filePath) {
|
if (doc?.id && doc?.filePath) {
|
||||||
loadFile(doc.id);
|
fileLoader.loadFile(`/api/documents/${doc.id}/file`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadFile(id: string) {
|
onDestroy(() => fileLoader.destroy());
|
||||||
isLoading = true;
|
|
||||||
fileError = '';
|
|
||||||
fileUrl = '';
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
fileUrl = URL.createObjectURL(blob);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
fileError = 'Vorschau konnte nicht geladen werden.';
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mode state ───────────────────────────────────────────────────────────────
|
// ── Mode state ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -345,7 +323,7 @@ onMount(() => {
|
|||||||
<DocumentTopBar
|
<DocumentTopBar
|
||||||
doc={doc}
|
doc={doc}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileLoader.fileUrl}
|
||||||
bind:transcribeMode={transcribeMode}
|
bind:transcribeMode={transcribeMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -357,9 +335,9 @@ onMount(() => {
|
|||||||
>
|
>
|
||||||
<DocumentViewer
|
<DocumentViewer
|
||||||
doc={doc}
|
doc={doc}
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileLoader.fileUrl}
|
||||||
isLoading={isLoading}
|
isLoading={fileLoader.isLoading}
|
||||||
error={fileError}
|
error={fileLoader.fileError}
|
||||||
transcribeMode={transcribeMode && !ocrRunning}
|
transcribeMode={transcribeMode && !ocrRunning}
|
||||||
blockNumbers={blockNumbers}
|
blockNumbers={blockNumbers}
|
||||||
annotationReloadKey={annotationReloadKey}
|
annotationReloadKey={annotationReloadKey}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, onDestroy, untrack } from 'svelte';
|
||||||
|
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
|
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
|
||||||
@@ -11,9 +12,7 @@ let { data, form } = $props();
|
|||||||
const doc = $derived(data.document);
|
const doc = $derived(data.document);
|
||||||
|
|
||||||
// File preview state
|
// File preview state
|
||||||
let fileUrl = $state('');
|
const fileLoader = createFileLoader();
|
||||||
let fileError = $state('');
|
|
||||||
let isLoading = $state(false);
|
|
||||||
|
|
||||||
let navHeight = $state(0);
|
let navHeight = $state(0);
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -27,25 +26,11 @@ let activeAnnotationPage = $state<number | null>(null);
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (doc?.id && doc?.filePath) {
|
if (doc?.id && doc?.filePath) {
|
||||||
loadFile(doc.id);
|
fileLoader.loadFile(`/api/documents/${doc.id}/file`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadFile(id: string) {
|
onDestroy(() => fileLoader.destroy());
|
||||||
isLoading = true;
|
|
||||||
fileError = '';
|
|
||||||
fileUrl = '';
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/documents/${id}/file`);
|
|
||||||
if (!response.ok) throw new Error('Fehler');
|
|
||||||
const blob = await response.blob();
|
|
||||||
fileUrl = URL.createObjectURL(blob);
|
|
||||||
} catch {
|
|
||||||
fileError = m.doc_file_error_preview();
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? []));
|
let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? []));
|
||||||
@@ -88,9 +73,9 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
|
|||||||
<div class="relative flex-[6] overflow-hidden border-r border-line">
|
<div class="relative flex-[6] overflow-hidden border-r border-line">
|
||||||
<DocumentViewer
|
<DocumentViewer
|
||||||
doc={doc}
|
doc={doc}
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileLoader.fileUrl}
|
||||||
isLoading={isLoading}
|
isLoading={fileLoader.isLoading}
|
||||||
error={fileError}
|
error={fileLoader.fileError}
|
||||||
bind:annotateMode={annotateMode}
|
bind:annotateMode={annotateMode}
|
||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
bind:activeAnnotationPage={activeAnnotationPage}
|
bind:activeAnnotationPage={activeAnnotationPage}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11.9-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -21,6 +21,8 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
CMD ["/app/entrypoint.sh"]
|
||||||
|
|||||||
80
ocr-service/ensure_blla_model.py
Normal file
80
ocr-service/ensure_blla_model.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Validates the blla segmentation base model and downloads it if needed.
|
||||||
|
|
||||||
|
Run at container startup before uvicorn. ketos 7 requires the model in
|
||||||
|
CoreML protobuf or safetensors format — legacy PyTorch ZIP archives
|
||||||
|
(torch.save output from kraken <4) are not loadable and will be replaced.
|
||||||
|
|
||||||
|
Exits non-zero on failure so Docker marks the container unhealthy rather
|
||||||
|
than silently starting with a broken model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(levelname)s:ensure_blla_model:%(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BLLA_MODEL_PATH = os.environ.get("BLLA_MODEL_PATH", "/app/models/blla.mlmodel")
|
||||||
|
# DOI for "General segmentation model for print and handwriting" — ketos 7 compatible.
|
||||||
|
BLLA_MODEL_DOI = "10.5281/zenodo.14602569"
|
||||||
|
HTRMOPO_DIR = os.path.expanduser("~/.local/share/htrmopo")
|
||||||
|
|
||||||
|
|
||||||
|
def _model_is_loadable(path: str) -> bool:
|
||||||
|
try:
|
||||||
|
from kraken.lib import vgsl
|
||||||
|
|
||||||
|
vgsl.TorchVGSLModel.load_model(path)
|
||||||
|
return True
|
||||||
|
except (RuntimeError, OSError, ValueError) as e:
|
||||||
|
log.warning("Model at %s failed to load: %s", path, e)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
log.debug("Unexpected error loading model at %s", path, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _download_blla() -> str:
|
||||||
|
log.info("Downloading blla model (DOI %s) ...", BLLA_MODEL_DOI)
|
||||||
|
result = subprocess.run(
|
||||||
|
["kraken", "get", BLLA_MODEL_DOI],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
log.error("kraken get failed: %s", result.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
candidates = sorted(glob.glob(os.path.join(HTRMOPO_DIR, "*/blla.mlmodel")))
|
||||||
|
if not candidates:
|
||||||
|
log.error("Downloaded blla.mlmodel not found under %s", HTRMOPO_DIR)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return candidates[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if os.path.exists(BLLA_MODEL_PATH):
|
||||||
|
if _model_is_loadable(BLLA_MODEL_PATH):
|
||||||
|
log.info("blla model OK: %s", BLLA_MODEL_PATH)
|
||||||
|
return
|
||||||
|
log.warning(
|
||||||
|
"blla model at %s is in an incompatible format — replacing", BLLA_MODEL_PATH
|
||||||
|
)
|
||||||
|
os.rename(BLLA_MODEL_PATH, BLLA_MODEL_PATH + ".incompatible")
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(BLLA_MODEL_PATH), exist_ok=True)
|
||||||
|
downloaded = _download_blla()
|
||||||
|
shutil.copy2(downloaded, BLLA_MODEL_PATH)
|
||||||
|
log.info("Installed blla model at %s", BLLA_MODEL_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
9
ocr-service/entrypoint.sh
Normal file
9
ocr-service/entrypoint.sh
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Validate the blla segmentation base model and download it if missing or
|
||||||
|
# incompatible. ketos 7 dropped support for legacy PyTorch ZIP archives —
|
||||||
|
# this ensures the volume always holds a loadable CoreML protobuf model.
|
||||||
|
python3 /app/ensure_blla_model.py
|
||||||
|
|
||||||
|
exec uvicorn main:app --host 0.0.0.0 --port 8000 --workers 1
|
||||||
@@ -472,16 +472,35 @@ async def segtrain_model(
|
|||||||
"-q", "fixed",
|
"-q", "fixed",
|
||||||
"-N", "10",
|
"-N", "10",
|
||||||
]
|
]
|
||||||
|
# Train at 800px height. The default blla model uses 1800px, which peaks at
|
||||||
|
# ~7+ GB on CPU and kills the host (ketos ignores -s when -i is present, so
|
||||||
|
# we cannot override the height of an existing model).
|
||||||
|
# Strategy: only use the base model if it is already at 800px (i.e. was
|
||||||
|
# produced by a previous fine-tuning run here). Otherwise train from scratch —
|
||||||
|
# the first run bootstraps a 800px model; all subsequent runs fine-tune it.
|
||||||
|
seg_spec = (
|
||||||
|
"[1,800,0,3 Cr7,7,64,2,2 Gn32 Cr3,3,128,2,2 Gn32 Cr3,3,128 Gn32 "
|
||||||
|
"Cr3,3,256 Gn32 Cr3,3,256 Gn32 Lbx32 Lby32 Cr1,1,32 Gn32 Lby32 Lbx32]"
|
||||||
|
)
|
||||||
|
use_base_model = False
|
||||||
if os.path.exists(blla_model_path):
|
if os.path.exists(blla_model_path):
|
||||||
cmd += ["-i", blla_model_path, "--resize", "both"]
|
try:
|
||||||
|
from kraken.lib import vgsl as _vgsl
|
||||||
|
_m = _vgsl.TorchVGSLModel.load_model(blla_model_path)
|
||||||
|
use_base_model = _m.input[2] == 800 # input is (batch, channels, H, W)
|
||||||
|
if not use_base_model:
|
||||||
|
log.info(
|
||||||
|
"Base model height is %dpx — skipping -i to avoid OOM; "
|
||||||
|
"will train from scratch at 800px",
|
||||||
|
_m.input[2],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Could not inspect base model height, training from scratch: %s", exc)
|
||||||
|
|
||||||
|
if use_base_model:
|
||||||
|
cmd += ["-i", blla_model_path, "--resize", "union", "-s", seg_spec]
|
||||||
else:
|
else:
|
||||||
# No pretrained model — train from scratch with reduced height (800px)
|
cmd += ["-s", seg_spec]
|
||||||
# to keep peak RAM under ~200 MB on CPU (default 1800px uses ~500 MB+)
|
|
||||||
cmd += [
|
|
||||||
"-s",
|
|
||||||
"[1,800,0,3 Cr7,7,64,2,2 Gn32 Cr3,3,128,2,2 Gn32 Cr3,3,128 Gn32 "
|
|
||||||
"Cr3,3,256 Gn32 Cr3,3,256 Gn32 Lbx32 Lby32 Cr1,1,32 Gn32 Lby32 Lbx32]",
|
|
||||||
]
|
|
||||||
cmd += xml_files
|
cmd += xml_files
|
||||||
|
|
||||||
log.info("Running: %s", " ".join(cmd[:5]) + " ...")
|
log.info("Running: %s", " ".join(cmd[:5]) + " ...")
|
||||||
@@ -493,7 +512,8 @@ async def segtrain_model(
|
|||||||
raise RuntimeError(f"ketos segtrain failed (exit {proc.returncode}): {proc.stderr[-500:]}")
|
raise RuntimeError(f"ketos segtrain failed (exit {proc.returncode}): {proc.stderr[-500:]}")
|
||||||
|
|
||||||
accuracy, epochs = _parse_best_checkpoint(checkpoint_dir)
|
accuracy, epochs = _parse_best_checkpoint(checkpoint_dir)
|
||||||
log.info("Segmentation training complete — epochs=%s accuracy=%s", epochs, accuracy)
|
cer = round(1.0 - accuracy, 4) if accuracy is not None else None
|
||||||
|
log.info("Segmentation training complete — epochs=%s accuracy=%s cer=%s", epochs, accuracy, cer)
|
||||||
|
|
||||||
best_model = _find_best_model(checkpoint_dir)
|
best_model = _find_best_model(checkpoint_dir)
|
||||||
if best_model is None:
|
if best_model is None:
|
||||||
@@ -508,7 +528,7 @@ async def segtrain_model(
|
|||||||
shutil.copy2(best_model, blla_model_path)
|
shutil.copy2(best_model, blla_model_path)
|
||||||
log.info("Replaced blla model at %s", blla_model_path)
|
log.info("Replaced blla model at %s", blla_model_path)
|
||||||
|
|
||||||
return {"loss": None, "accuracy": accuracy, "cer": None, "epochs": epochs}
|
return {"loss": None, "accuracy": accuracy, "cer": cer, "epochs": epochs}
|
||||||
|
|
||||||
result = await asyncio.to_thread(_run_segtrain)
|
result = await asyncio.to_thread(_run_segtrain)
|
||||||
return result
|
return result
|
||||||
|
|||||||
69
ocr-service/test_ensure_blla_model.py
Normal file
69
ocr-service/test_ensure_blla_model.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Unit tests for ensure_blla_model.main()."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
|
import ensure_blla_model
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Model already loadable ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_returns_early_when_model_is_loadable():
|
||||||
|
"""When the model exists and loads cleanly, no download or rename occurs."""
|
||||||
|
with (
|
||||||
|
patch("os.path.exists", return_value=True),
|
||||||
|
patch.object(ensure_blla_model, "_model_is_loadable", return_value=True),
|
||||||
|
patch.object(ensure_blla_model, "_download_blla") as mock_download,
|
||||||
|
patch("os.rename") as mock_rename,
|
||||||
|
):
|
||||||
|
ensure_blla_model.main()
|
||||||
|
|
||||||
|
mock_download.assert_not_called()
|
||||||
|
mock_rename.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Model exists but is incompatible ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_replaces_incompatible_model():
|
||||||
|
"""An incompatible model is renamed and replaced with a fresh download."""
|
||||||
|
fake_path = "/app/models/blla.mlmodel"
|
||||||
|
downloaded_path = "/tmp/downloaded.mlmodel"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(ensure_blla_model, "BLLA_MODEL_PATH", fake_path),
|
||||||
|
patch("os.path.exists", return_value=True),
|
||||||
|
patch.object(ensure_blla_model, "_model_is_loadable", return_value=False),
|
||||||
|
patch.object(ensure_blla_model, "_download_blla", return_value=downloaded_path),
|
||||||
|
patch("os.rename") as mock_rename,
|
||||||
|
patch("shutil.copy2") as mock_copy,
|
||||||
|
patch("os.makedirs"),
|
||||||
|
):
|
||||||
|
ensure_blla_model.main()
|
||||||
|
|
||||||
|
mock_rename.assert_called_once_with(fake_path, fake_path + ".incompatible")
|
||||||
|
mock_copy.assert_called_once_with(downloaded_path, fake_path)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Model missing ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_downloads_when_model_missing():
|
||||||
|
"""When the model file doesn't exist at all, it is downloaded without rename."""
|
||||||
|
fake_path = "/app/models/blla.mlmodel"
|
||||||
|
downloaded_path = "/tmp/downloaded.mlmodel"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(ensure_blla_model, "BLLA_MODEL_PATH", fake_path),
|
||||||
|
patch("os.path.exists", return_value=False),
|
||||||
|
patch.object(ensure_blla_model, "_model_is_loadable") as mock_loadable,
|
||||||
|
patch.object(ensure_blla_model, "_download_blla", return_value=downloaded_path),
|
||||||
|
patch("os.rename") as mock_rename,
|
||||||
|
patch("shutil.copy2") as mock_copy,
|
||||||
|
patch("os.makedirs"),
|
||||||
|
):
|
||||||
|
ensure_blla_model.main()
|
||||||
|
|
||||||
|
mock_loadable.assert_not_called()
|
||||||
|
mock_rename.assert_not_called()
|
||||||
|
mock_copy.assert_called_once_with(downloaded_path, fake_path)
|
||||||
Reference in New Issue
Block a user