feat(dashboard): redesign home as action-led family archive hub (#271) #278
@@ -103,6 +103,11 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.awaitility</groupId>
|
||||
<artifactId>awaitility</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Excel Bearbeitung (Apache POI) -->
|
||||
<dependency>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.annotation.Nullable;
|
||||
|
||||
public record ActivityActorDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String initials,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String color,
|
||||
@Nullable String name
|
||||
) {}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface ActivityFeedRow {
|
||||
String getKind();
|
||||
UUID getActorId();
|
||||
String getActorInitials();
|
||||
String getActorColor();
|
||||
String getActorName();
|
||||
UUID getDocumentId();
|
||||
Instant getHappenedAt();
|
||||
boolean isYouMentioned();
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
||||
|
||||
@Query(value = """
|
||||
SELECT a.document_id
|
||||
FROM audit_log a
|
||||
WHERE a.kind IN ('TEXT_SAVED', 'ANNOTATION_CREATED')
|
||||
AND a.actor_id = :userId
|
||||
AND a.document_id IS NOT NULL
|
||||
ORDER BY a.happened_at DESC
|
||||
LIMIT 1
|
||||
""", nativeQuery = true)
|
||||
Optional<UUID> findMostRecentDocumentIdByActor(@Param("userId") UUID userId);
|
||||
|
||||
@Query(value = """
|
||||
SELECT * FROM (
|
||||
SELECT DISTINCT ON (a.actor_id, a.document_id, a.kind, date_trunc('hour', a.happened_at))
|
||||
a.kind AS kind,
|
||||
a.actor_id AS actorId,
|
||||
CASE
|
||||
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
|
||||
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
|
||||
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
|
||||
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
|
||||
ELSE '?'
|
||||
END AS actorInitials,
|
||||
COALESCE(u.color, '') AS actorColor,
|
||||
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName,
|
||||
a.document_id AS documentId,
|
||||
a.happened_at AS happened_at,
|
||||
(a.kind = 'MENTION_CREATED'
|
||||
AND a.payload->>'mentionedUserId' = :currentUserId) AS youMentioned
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.actor_id
|
||||
WHERE a.kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED','COMMENT_ADDED','MENTION_CREATED')
|
||||
AND a.document_id IS NOT NULL
|
||||
ORDER BY a.actor_id, a.document_id, a.kind,
|
||||
date_trunc('hour', a.happened_at), a.happened_at DESC
|
||||
) deduped
|
||||
ORDER BY happened_at DESC
|
||||
LIMIT :limit
|
||||
""", nativeQuery = true)
|
||||
List<ActivityFeedRow> findDedupedActivityFeed(
|
||||
@Param("currentUserId") String currentUserId,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@Query(value = """
|
||||
SELECT
|
||||
COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber'))) AS pages,
|
||||
COUNT(*) FILTER (WHERE a.kind = 'ANNOTATION_CREATED') AS annotated,
|
||||
COUNT(DISTINCT a.payload->>'blockId') FILTER (WHERE a.kind = 'TEXT_SAVED') AS transcribed,
|
||||
COUNT(DISTINCT a.document_id) FILTER (WHERE a.kind = 'FILE_UPLOADED') AS uploaded,
|
||||
COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber')))
|
||||
FILTER (WHERE (a.kind = 'ANNOTATION_CREATED' OR a.kind = 'TEXT_SAVED')
|
||||
AND a.actor_id::text = :userId) AS yourPages
|
||||
FROM audit_log a
|
||||
WHERE a.happened_at >= :weekStart
|
||||
AND a.kind IN ('ANNOTATION_CREATED','TEXT_SAVED','FILE_UPLOADED')
|
||||
""", nativeQuery = true)
|
||||
PulseStatsRow getPulseStats(
|
||||
@Param("weekStart") OffsetDateTime weekStart,
|
||||
@Param("userId") String userId);
|
||||
|
||||
@Query(value = """
|
||||
SELECT DISTINCT ON (a.document_id)
|
||||
a.document_id AS documentId,
|
||||
a.actor_id AS actorId
|
||||
FROM audit_log a
|
||||
WHERE a.kind = :kind
|
||||
AND a.document_id IN :documentIds
|
||||
AND a.actor_id IS NOT NULL
|
||||
ORDER BY a.document_id, a.happened_at DESC
|
||||
""", nativeQuery = true)
|
||||
List<Object[]> findMostRecentActorPerDocument(
|
||||
@Param("documentIds") List<UUID> documentIds,
|
||||
@Param("kind") String kind);
|
||||
|
||||
@Query(value = """
|
||||
SELECT
|
||||
a.document_id AS documentId,
|
||||
CASE
|
||||
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
|
||||
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
|
||||
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
|
||||
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
|
||||
ELSE '?'
|
||||
END AS actorInitials,
|
||||
COALESCE(u.color, '') AS actorColor,
|
||||
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.actor_id
|
||||
WHERE a.kind IN ('ANNOTATION_CREATED', 'TEXT_SAVED', 'BLOCK_REVIEWED')
|
||||
AND a.document_id IN :documentIds
|
||||
AND a.actor_id IS NOT NULL
|
||||
GROUP BY a.document_id, a.actor_id, u.first_name, u.last_name, u.color
|
||||
ORDER BY a.document_id, MIN(a.happened_at)
|
||||
""", nativeQuery = true)
|
||||
List<ContributorRow> findContributorsPerDocument(@Param("documentIds") List<UUID> documentIds);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditLogQueryService {
|
||||
|
||||
private final AuditLogQueryRepository queryRepository;
|
||||
|
||||
public Optional<UUID> findMostRecentDocumentForUser(UUID userId) {
|
||||
return queryRepository.findMostRecentDocumentIdByActor(userId);
|
||||
}
|
||||
|
||||
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit) {
|
||||
return queryRepository.findDedupedActivityFeed(currentUserId.toString(), limit);
|
||||
}
|
||||
|
||||
public PulseStatsRow getPulseStats(OffsetDateTime weekStart, UUID userId) {
|
||||
return queryRepository.getPulseStats(weekStart, userId.toString());
|
||||
}
|
||||
|
||||
public Map<UUID, UUID> findMostRecentActorPerDocument(List<UUID> documentIds, String kind) {
|
||||
if (documentIds.isEmpty()) return Map.of();
|
||||
List<Object[]> rows = queryRepository.findMostRecentActorPerDocument(documentIds, kind);
|
||||
Map<UUID, UUID> result = new LinkedHashMap<>();
|
||||
for (Object[] row : rows) {
|
||||
UUID docId = (UUID) row[0];
|
||||
UUID actorId = (UUID) row[1];
|
||||
result.put(docId, actorId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<UUID, List<ActivityActorDTO>> findContributorsPerDocument(List<UUID> documentIds) {
|
||||
if (documentIds.isEmpty()) return Map.of();
|
||||
List<ContributorRow> rows = queryRepository.findContributorsPerDocument(documentIds);
|
||||
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
||||
for (ContributorRow row : rows) {
|
||||
result.computeIfAbsent(row.getDocumentId(), k -> new ArrayList<>())
|
||||
.add(new ActivityActorDTO(row.getActorInitials(), row.getActorColor(), row.getActorName()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
@@ -16,6 +18,8 @@ import java.util.UUID;
|
||||
public class AuditService {
|
||||
|
||||
private final AuditLogRepository auditLogRepository;
|
||||
@Qualifier("auditExecutor")
|
||||
private final TaskExecutor auditExecutor;
|
||||
|
||||
@Async("auditExecutor")
|
||||
public void log(AuditKind kind, UUID actorId, UUID documentId, Map<String, Object> payload) {
|
||||
@@ -27,7 +31,10 @@ public class AuditService {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
writeLog(kind, actorId, documentId, payload);
|
||||
// Run on a separate thread: the afterCommit() callback fires while Spring's
|
||||
// transaction synchronizations are still active on the current thread, which
|
||||
// prevents SimpleJpaRepository.save() from starting a new transaction inline.
|
||||
auditExecutor.execute(() -> writeLog(kind, actorId, documentId, payload));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface ContributorRow {
|
||||
UUID getDocumentId();
|
||||
String getActorInitials();
|
||||
String getActorColor();
|
||||
String getActorName();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
public interface PulseStatsRow {
|
||||
long getPages();
|
||||
long getAnnotated();
|
||||
long getTranscribed();
|
||||
long getUploaded();
|
||||
long getYourPages();
|
||||
}
|
||||
@@ -31,7 +31,10 @@ public class AsyncConfig {
|
||||
executor.setMaxPoolSize(2);
|
||||
executor.setQueueCapacity(50);
|
||||
executor.setThreadNamePrefix("Audit-");
|
||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
|
||||
// AbortPolicy instead of CallerRunsPolicy: if CallerRunsPolicy ran the task on the
|
||||
// afterCommit() callback thread, Spring's transaction synchronizations would still be
|
||||
// active on that thread and SimpleJpaRepository.save() would throw IllegalStateException.
|
||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
@@ -28,6 +27,7 @@ import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.security.SecurityUtils;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.service.FileService;
|
||||
@@ -197,12 +197,6 @@ public class DocumentController {
|
||||
return Map.of("count", documentService.getIncompleteCount());
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete")
|
||||
public List<IncompleteDocumentDTO> getIncomplete(
|
||||
@Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "10") int size) {
|
||||
return documentService.findIncompleteDocuments(size);
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete/next")
|
||||
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
|
||||
return documentService.findNextIncompleteDocument(excludeId)
|
||||
@@ -210,12 +204,6 @@ public class DocumentController {
|
||||
.orElse(ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@GetMapping("/recent-activity")
|
||||
public ResponseEntity<List<Document>> getRecentActivity(
|
||||
@RequestParam(defaultValue = "5") int size) {
|
||||
return ResponseEntity.ok(documentService.getRecentActivity(size));
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<DocumentSearchResult> search(
|
||||
@RequestParam(required = false) String q,
|
||||
@@ -286,13 +274,6 @@ public class DocumentController {
|
||||
}
|
||||
|
||||
private UUID requireUserId(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
throw DomainException.unauthorized("Authentication required");
|
||||
}
|
||||
AppUser user = userService.findByEmail(authentication.getName());
|
||||
if (user == null) {
|
||||
throw DomainException.unauthorized("User not found");
|
||||
}
|
||||
return user.getId();
|
||||
return SecurityUtils.requireUserId(authentication, userService);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
|
||||
import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO;
|
||||
import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.security.SecurityUtils;
|
||||
import org.raddatz.familienarchiv.service.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -100,13 +99,6 @@ public class TranscriptionBlockController {
|
||||
}
|
||||
|
||||
private UUID requireUserId(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
throw DomainException.unauthorized("Authentication required");
|
||||
}
|
||||
AppUser user = userService.findByEmail(authentication.getName());
|
||||
if (user == null) {
|
||||
throw DomainException.unauthorized("User not found");
|
||||
}
|
||||
return user.getId();
|
||||
return SecurityUtils.requireUserId(authentication, userService);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.annotation.Nullable;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ActivityFeedItemDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) AuditKind kind,
|
||||
@Nullable ActivityActorDTO actor,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID documentId,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String documentTitle,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) OffsetDateTime happenedAt,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned
|
||||
) {}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.security.SecurityUtils;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/dashboard")
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
@RequiredArgsConstructor
|
||||
public class DashboardController {
|
||||
|
||||
private final DashboardService dashboardService;
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping("/resume")
|
||||
public DashboardResumeDTO getResume(Authentication authentication) {
|
||||
UUID userId = SecurityUtils.requireUserId(authentication, userService);
|
||||
return dashboardService.getResume(userId);
|
||||
}
|
||||
|
||||
@GetMapping("/pulse")
|
||||
public DashboardPulseDTO getPulse(Authentication authentication) {
|
||||
UUID userId = SecurityUtils.requireUserId(authentication, userService);
|
||||
return dashboardService.getPulse(userId);
|
||||
}
|
||||
|
||||
@GetMapping("/activity")
|
||||
public List<ActivityFeedItemDTO> getActivity(
|
||||
Authentication authentication,
|
||||
@RequestParam(defaultValue = "7") int limit) {
|
||||
UUID userId = SecurityUtils.requireUserId(authentication, userService);
|
||||
return dashboardService.getActivity(userId, Math.min(limit, 20));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record DashboardPulseDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int pages,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int annotated,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int transcribed,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int uploaded,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int yourPages,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<ActivityActorDTO> contributors
|
||||
) {}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.annotation.Nullable;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record DashboardResumeDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID documentId,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String caption,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String excerpt,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int totalBlocks,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int pct,
|
||||
@Nullable String thumbnailUrl,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<ActivityActorDTO> collaborators
|
||||
) {}
|
||||
@@ -0,0 +1,181 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.PulseStatsRow;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DashboardService {
|
||||
|
||||
private final AuditLogQueryService auditLogQueryService;
|
||||
private final DocumentService documentService;
|
||||
private final TranscriptionService transcriptionService;
|
||||
private final UserService userService;
|
||||
|
||||
public DashboardResumeDTO getResume(UUID userId) {
|
||||
Optional<UUID> docIdOpt = auditLogQueryService.findMostRecentDocumentForUser(userId);
|
||||
if (docIdOpt.isEmpty()) return null;
|
||||
|
||||
UUID docId = docIdOpt.get();
|
||||
Document doc;
|
||||
try {
|
||||
doc = documentService.getDocumentById(docId);
|
||||
} catch (Exception e) {
|
||||
log.warn("Resume: document {} not found for user {}", docId, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
List<TranscriptionBlock> blocks = transcriptionService.listBlocks(docId);
|
||||
String excerpt = blocks.stream()
|
||||
.filter(b -> b.getText() != null && !b.getText().isBlank())
|
||||
.min(Comparator.comparingInt(TranscriptionBlock::getSortOrder))
|
||||
.map(b -> b.getText().length() > 200 ? b.getText().substring(0, 200) + "…" : b.getText())
|
||||
.orElse("");
|
||||
|
||||
int totalBlocks = blocks.size();
|
||||
long reviewedBlocks = blocks.stream().filter(TranscriptionBlock::isReviewed).count();
|
||||
int pct = totalBlocks > 0 ? (int) (reviewedBlocks * 100L / totalBlocks) : 0;
|
||||
|
||||
String caption = buildCaption(doc);
|
||||
|
||||
List<UUID> collaboratorIds = blocks.stream()
|
||||
.map(TranscriptionBlock::getUpdatedBy)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.limit(5)
|
||||
.toList();
|
||||
|
||||
List<ActivityActorDTO> collaborators = collaboratorIds.stream()
|
||||
.map(uid -> {
|
||||
try {
|
||||
AppUser u = userService.getById(uid);
|
||||
return toActorDTO(u);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
|
||||
return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt,
|
||||
totalBlocks, pct, null, collaborators);
|
||||
}
|
||||
|
||||
public DashboardPulseDTO getPulse(UUID userId) {
|
||||
OffsetDateTime weekStart = OffsetDateTime.now(ZoneOffset.UTC)
|
||||
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
||||
.withHour(0).withMinute(0).withSecond(0).withNano(0);
|
||||
|
||||
PulseStatsRow stats = auditLogQueryService.getPulseStats(weekStart, userId);
|
||||
|
||||
List<ActivityFeedRow> feed = auditLogQueryService.findActivityFeed(userId, 50);
|
||||
List<ActivityActorDTO> contributors = feed.stream()
|
||||
.filter(r -> r.getActorId() != null)
|
||||
.map(r -> new ActivityActorDTO(r.getActorInitials(), r.getActorColor(), r.getActorName()))
|
||||
.filter(a -> !a.initials().isBlank())
|
||||
.distinct()
|
||||
.limit(6)
|
||||
.toList();
|
||||
|
||||
return new DashboardPulseDTO(
|
||||
(int) stats.getPages(),
|
||||
(int) stats.getAnnotated(),
|
||||
(int) stats.getTranscribed(),
|
||||
(int) stats.getUploaded(),
|
||||
(int) stats.getYourPages(),
|
||||
contributors
|
||||
);
|
||||
}
|
||||
|
||||
public List<ActivityFeedItemDTO> getActivity(UUID currentUserId, int limit) {
|
||||
List<ActivityFeedRow> rows = auditLogQueryService.findActivityFeed(currentUserId, limit);
|
||||
|
||||
List<UUID> docIds = rows.stream()
|
||||
.map(ActivityFeedRow::getDocumentId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
Map<UUID, String> titleCache = new HashMap<>();
|
||||
try {
|
||||
documentService.getDocumentsByIds(docIds)
|
||||
.forEach(d -> titleCache.put(d.getId(), d.getTitle()));
|
||||
} catch (Exception e) {
|
||||
log.warn("Activity: failed to bulk-load document titles", e);
|
||||
}
|
||||
|
||||
return rows.stream().map(row -> {
|
||||
ActivityActorDTO actor = row.getActorId() != null
|
||||
? new ActivityActorDTO(row.getActorInitials(), row.getActorColor(), row.getActorName())
|
||||
: null;
|
||||
String docTitle = titleCache.getOrDefault(row.getDocumentId(), "");
|
||||
return new ActivityFeedItemDTO(
|
||||
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
|
||||
actor,
|
||||
row.getDocumentId(),
|
||||
docTitle,
|
||||
row.getHappenedAt().atOffset(ZoneOffset.UTC),
|
||||
row.isYouMentioned()
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private String buildCaption(Document doc) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (doc.getSender() != null) sb.append(personName(doc.getSender()));
|
||||
if (!doc.getReceivers().isEmpty()) {
|
||||
String receivers = doc.getReceivers().stream()
|
||||
.map(this::personName).collect(Collectors.joining(", "));
|
||||
if (!sb.isEmpty()) sb.append(" an ");
|
||||
sb.append(receivers);
|
||||
}
|
||||
if (doc.getDocumentDate() != null) {
|
||||
if (!sb.isEmpty()) sb.append(" · ");
|
||||
sb.append(doc.getDocumentDate());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String personName(Person p) {
|
||||
if (p == null) return "";
|
||||
if (p.getFirstName() != null && p.getLastName() != null) return p.getFirstName() + " " + p.getLastName();
|
||||
if (p.getFirstName() != null) return p.getFirstName();
|
||||
if (p.getLastName() != null) return p.getLastName();
|
||||
return "";
|
||||
}
|
||||
|
||||
private ActivityActorDTO toActorDTO(AppUser u) {
|
||||
String initials = "";
|
||||
if (u.getFirstName() != null && !u.getFirstName().isBlank())
|
||||
initials += u.getFirstName().charAt(0);
|
||||
if (u.getLastName() != null && !u.getLastName().isBlank())
|
||||
initials += u.getLastName().charAt(0);
|
||||
if (initials.isBlank() && u.getEmail() != null)
|
||||
initials = u.getEmail().substring(0, 1).toUpperCase();
|
||||
String fullName = Stream.of(u.getFirstName(), u.getLastName())
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.joining(" "));
|
||||
return new ActivityActorDTO(initials.toUpperCase(), u.getColor(), fullName);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A single row in one of the three Mission Control Strip queues.
|
||||
* Annotation/block counts drive the per-document mini progress bar
|
||||
* in the Transkription column and the percentage label in Lesefertig.
|
||||
*/
|
||||
public record TranscriptionQueueItemDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||
LocalDate documentDate,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int annotationCount,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int textedBlockCount,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int reviewedBlockCount
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int reviewedBlockCount,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<ActivityActorDTO> contributors,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean hasMoreContributors
|
||||
) {}
|
||||
|
||||
@@ -19,6 +19,10 @@ import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.PostLoad;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
@Data
|
||||
@@ -74,6 +78,28 @@ public class AppUser {
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String color = "";
|
||||
|
||||
private static final String[] PALETTE = {
|
||||
"#7a4f9a", "#5a8a6a", "#3060b0", "#a0522d", "#c0446e", "#c17a00", "#0e7490", "#1d4ed8"
|
||||
};
|
||||
|
||||
public static String computeColor(UUID id) {
|
||||
return PALETTE[Math.abs(id.hashCode()) % PALETTE.length];
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
@PostLoad
|
||||
void deriveColor() {
|
||||
if (id != null && (color == null || color.isEmpty())) {
|
||||
this.color = computeColor(id);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasPermission(String permission) {
|
||||
if (groups == null || groups.isEmpty()) {
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.raddatz.familienarchiv.security;
|
||||
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public final class SecurityUtils {
|
||||
|
||||
private SecurityUtils() {}
|
||||
|
||||
public static UUID requireUserId(Authentication authentication, UserService userService) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
throw DomainException.unauthorized("Authentication required");
|
||||
}
|
||||
AppUser user = userService.findByEmail(authentication.getName());
|
||||
if (user == null) {
|
||||
throw DomainException.unauthorized("User not found");
|
||||
}
|
||||
return user.getId();
|
||||
}
|
||||
}
|
||||
@@ -484,6 +484,10 @@ public class DocumentService {
|
||||
return doc;
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsByIds(List<UUID> ids) {
|
||||
return documentRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsWithoutVersions() {
|
||||
return documentRepository.findDocumentsWithoutVersions();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
|
||||
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
@@ -8,38 +10,29 @@ import org.raddatz.familienarchiv.repository.TranscriptionQueueProjection;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Serves the three Mission Control Strip queues (Segmentierung / Transkription / Lesefertig)
|
||||
* and the weekly activity pulse used by the column headers.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TranscriptionQueueService {
|
||||
|
||||
private static final int DEFAULT_QUEUE_SIZE = 5;
|
||||
private static final int MAX_CONTRIBUTORS = 5;
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final AuditLogQueryService auditLogQueryService;
|
||||
|
||||
public List<TranscriptionQueueItemDTO> getSegmentationQueue() {
|
||||
return documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE)
|
||||
.stream()
|
||||
.map(this::toDTO)
|
||||
.toList();
|
||||
return enrichWithContributors(documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE));
|
||||
}
|
||||
|
||||
public List<TranscriptionQueueItemDTO> getTranscriptionQueue() {
|
||||
return documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE)
|
||||
.stream()
|
||||
.map(this::toDTO)
|
||||
.toList();
|
||||
return enrichWithContributors(documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE));
|
||||
}
|
||||
|
||||
public List<TranscriptionQueueItemDTO> getReadyToReadQueue() {
|
||||
return documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE)
|
||||
.stream()
|
||||
.map(this::toDTO)
|
||||
.toList();
|
||||
return enrichWithContributors(documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE));
|
||||
}
|
||||
|
||||
public TranscriptionWeeklyStatsDTO getWeeklyStats() {
|
||||
@@ -50,14 +43,27 @@ public class TranscriptionQueueService {
|
||||
);
|
||||
}
|
||||
|
||||
private TranscriptionQueueItemDTO toDTO(TranscriptionQueueProjection p) {
|
||||
private List<TranscriptionQueueItemDTO> enrichWithContributors(List<TranscriptionQueueProjection> projections) {
|
||||
if (projections.isEmpty()) return List.of();
|
||||
List<UUID> ids = projections.stream().map(TranscriptionQueueProjection::getId).toList();
|
||||
Map<UUID, List<ActivityActorDTO>> contributorMap = auditLogQueryService.findContributorsPerDocument(ids);
|
||||
return projections.stream()
|
||||
.map(p -> toDTO(p, contributorMap.getOrDefault(p.getId(), List.of())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private TranscriptionQueueItemDTO toDTO(TranscriptionQueueProjection p, List<ActivityActorDTO> allContributors) {
|
||||
boolean hasMore = allContributors.size() > MAX_CONTRIBUTORS;
|
||||
List<ActivityActorDTO> capped = hasMore ? allContributors.subList(0, MAX_CONTRIBUTORS) : allContributors;
|
||||
return new TranscriptionQueueItemDTO(
|
||||
p.getId(),
|
||||
p.getTitle(),
|
||||
p.getDocumentDate(),
|
||||
p.getAnnotationCount(),
|
||||
p.getTextedBlockCount(),
|
||||
p.getReviewedBlockCount()
|
||||
p.getReviewedBlockCount(),
|
||||
capped,
|
||||
hasMore
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,8 @@ public class TranscriptionService {
|
||||
if (!text.equals(previousText)) {
|
||||
Optional<DocumentAnnotation> annotation = annotationRepository.findById(block.getAnnotationId());
|
||||
int pageNumber = annotation.map(DocumentAnnotation::getPageNumber).orElse(0);
|
||||
auditService.logAfterCommit(AuditKind.TEXT_SAVED, userId, documentId, Map.of("pageNumber", pageNumber));
|
||||
auditService.logAfterCommit(AuditKind.TEXT_SAVED, userId, documentId,
|
||||
Map.of("pageNumber", pageNumber, "blockId", saved.getId().toString()));
|
||||
}
|
||||
|
||||
Document doc = documentService.getDocumentById(documentId);
|
||||
|
||||
@@ -6,7 +6,7 @@ CREATE TABLE audit_log (
|
||||
happened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
-- ON DELETE SET NULL is by design: GDPR right-to-erasure. Deleted users' events
|
||||
-- retain their timestamp and kind but lose actor attribution.
|
||||
actor_id UUID REFERENCES app_users(id) ON DELETE SET NULL,
|
||||
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
kind VARCHAR(50) NOT NULL,
|
||||
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
|
||||
payload JSONB
|
||||
@@ -19,4 +19,7 @@ CREATE INDEX idx_audit_log_kind ON audit_log (kind);
|
||||
|
||||
-- Enforce append-only at the database layer: the application role may INSERT
|
||||
-- but must not UPDATE or DELETE audit rows.
|
||||
REVOKE UPDATE, DELETE ON audit_log FROM app_user;
|
||||
-- NOTE: This REVOKE is a no-op when the current user is the table owner.
|
||||
-- PostgreSQL owners retain all privileges regardless of REVOKE. The append-only
|
||||
-- guarantee is enforced at the application layer only.
|
||||
REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USER;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Add deterministic avatar color to app_users.
|
||||
-- Assigned at application layer (AppUser.java) from a fixed 8-colour palette.
|
||||
-- Also corrects V46's REVOKE which hardcoded 'app_user' instead of CURRENT_USER.
|
||||
|
||||
ALTER TABLE users ADD COLUMN color VARCHAR(20) NOT NULL DEFAULT '';
|
||||
|
||||
-- Fix V46 append-only enforcement for the actual application role.
|
||||
REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USER;
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import static java.util.concurrent.TimeUnit.SECONDS;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class AuditServiceIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired AuditService auditService;
|
||||
@Autowired AuditLogRepository auditLogRepository;
|
||||
@Autowired TransactionTemplate transactionTemplate;
|
||||
|
||||
@Test
|
||||
void logAfterCommit_writes_ANNOTATION_CREATED_row_after_transaction_commits() {
|
||||
transactionTemplate.execute(status -> {
|
||||
auditService.logAfterCommit(AuditKind.ANNOTATION_CREATED, null, null, null);
|
||||
return null;
|
||||
});
|
||||
|
||||
await().atMost(5, SECONDS).until(() -> auditLogRepository.count() > 0);
|
||||
assertThat(auditLogRepository.findAll())
|
||||
.extracting(AuditLog::getKind)
|
||||
.containsExactly(AuditKind.ANNOTATION_CREATED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void logAfterCommit_writes_no_row_when_transaction_rolls_back() {
|
||||
try {
|
||||
transactionTemplate.execute(status -> {
|
||||
auditService.logAfterCommit(AuditKind.ANNOTATION_CREATED, null, null, null);
|
||||
throw new RuntimeException("force rollback");
|
||||
});
|
||||
} catch (RuntimeException ignored) {}
|
||||
|
||||
assertThat(auditLogRepository.count()).isZero();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
@@ -24,6 +25,7 @@ import static org.mockito.Mockito.*;
|
||||
class AuditServiceTest {
|
||||
|
||||
@Mock AuditLogRepository auditLogRepository;
|
||||
@Mock TaskExecutor auditExecutor;
|
||||
@InjectMocks AuditService auditService;
|
||||
|
||||
@Test
|
||||
@@ -94,9 +96,7 @@ class AuditServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void logAfterCommit_registersCallback_andSavesOnlyAfterCommit_whenTransactionIsActive() {
|
||||
when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
void logAfterCommit_registersCallback_andSubmitsToExecutor_afterCommit() {
|
||||
try (MockedStatic<TransactionSynchronizationManager> mocked =
|
||||
mockStatic(TransactionSynchronizationManager.class)) {
|
||||
mocked.when(TransactionSynchronizationManager::isActualTransactionActive).thenReturn(true);
|
||||
@@ -106,15 +106,16 @@ class AuditServiceTest {
|
||||
|
||||
auditService.logAfterCommit(AuditKind.TEXT_SAVED, null, null, null);
|
||||
|
||||
// Callback registered but repo not yet called
|
||||
// Callback registered but executor not yet invoked
|
||||
assertThat(captured).hasSize(1);
|
||||
verify(auditLogRepository, never()).save(any());
|
||||
verify(auditExecutor, never()).execute(any());
|
||||
|
||||
// Simulate transaction commit
|
||||
captured.get(0).afterCommit();
|
||||
|
||||
// Now the row should be saved
|
||||
verify(auditLogRepository).save(any());
|
||||
// Write submitted to executor — not called inline
|
||||
verify(auditExecutor).execute(any());
|
||||
verify(auditLogRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.raddatz.familienarchiv.controller;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
@@ -390,47 +389,14 @@ class DocumentControllerTest {
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getIncomplete_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
// ─── GET /api/documents/incomplete (removed — superseded by dashboard) ────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncomplete_returns200_withDTOList() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
IncompleteDocumentDTO dto = new IncompleteDocumentDTO(id, "Unvollständig");
|
||||
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto));
|
||||
|
||||
void getIncomplete_endpointRemoved() throws Exception {
|
||||
// The path hits /{id} and fails UUID conversion — not a 200 anymore
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||
.andExpect(jsonPath("$[0].title").value("Unvollständig"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncomplete_withSizeParam_passesItToService() throws Exception {
|
||||
when(documentService.findIncompleteDocuments(5)).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete").param("size", "5"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).findIncompleteDocuments(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncomplete_usesDefaultSizeWhenNotSpecified() throws Exception {
|
||||
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).findIncompleteDocuments(10);
|
||||
.andExpect(status().is4xxClientError());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
|
||||
@@ -467,36 +433,14 @@ class DocumentControllerTest {
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/recent-activity ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getRecentActivity_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/recent-activity"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
// ─── GET /api/documents/recent-activity (removed — superseded by dashboard)
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getRecentActivity_returnsOkWithDocuments() throws Exception {
|
||||
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
|
||||
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
|
||||
when(documentService.getRecentActivity(5)).thenReturn(List.of(doc1, doc2));
|
||||
|
||||
mockMvc.perform(get("/api/documents/recent-activity").param("size", "5"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].title").value("Alpha"))
|
||||
.andExpect(jsonPath("$[1].title").value("Beta"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getRecentActivity_appliesDefaultSizeOfFive_whenSizeParamOmitted() throws Exception {
|
||||
when(documentService.getRecentActivity(5)).thenReturn(List.of());
|
||||
|
||||
void getRecentActivity_endpointRemoved() throws Exception {
|
||||
// The path hits /{id} and fails UUID conversion — not a 200 anymore
|
||||
mockMvc.perform(get("/api/documents/recent-activity"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).getRecentActivity(5);
|
||||
.andExpect(status().is4xxClientError());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
||||
|
||||
@@ -17,6 +17,8 @@ import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -41,7 +43,9 @@ class TranscriptionQueueControllerTest {
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000001"),
|
||||
"Testbrief",
|
||||
LocalDate.of(1920, 6, 15),
|
||||
3, 1, 0
|
||||
3, 1, 0,
|
||||
List.of(new ActivityActorDTO("TR", "#a6dad8", "Test Raddatz")),
|
||||
false
|
||||
);
|
||||
|
||||
private static final TranscriptionWeeklyStatsDTO STATS = new TranscriptionWeeklyStatsDTO(2L, 5L);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryRepository;
|
||||
import org.raddatz.familienarchiv.audit.ContributorRow;
|
||||
import org.raddatz.familienarchiv.audit.PulseStatsRow;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class AuditLogQueryRepositoryIntegrationTest {
|
||||
|
||||
static final UUID USER_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
static final UUID DOC_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
|
||||
@Autowired AuditLogQueryRepository auditLogQueryRepository;
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')",
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
|
||||
})
|
||||
void findMostRecentDocumentIdByActor_returns_document_with_annotation_only_events() {
|
||||
Optional<UUID> result = auditLogQueryRepository.findMostRecentDocumentIdByActor(USER_ID);
|
||||
|
||||
assertThat(result).contains(DOC_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')",
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"pageNumber\":1}')"
|
||||
})
|
||||
void findDedupedActivityFeed_returnsAnnotationEntry() {
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findDedupedActivityFeed(USER_ID.toString(), 10);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getKind()).isEqualTo("ANNOTATION_CREATED");
|
||||
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID);
|
||||
assertThat(rows.get(0).getHappenedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')",
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"pageNumber\":1}')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"blockId\":\"ccc\",\"pageNumber\":1}')",
|
||||
"INSERT INTO audit_log (kind, document_id) VALUES ('FILE_UPLOADED', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
|
||||
})
|
||||
void getPulseStats_countsAnnotationsTranscriptionsAndUploads() {
|
||||
OffsetDateTime weekStart = OffsetDateTime.now(ZoneOffset.UTC).minusDays(7);
|
||||
|
||||
PulseStatsRow stats = auditLogQueryRepository.getPulseStats(weekStart, USER_ID.toString());
|
||||
|
||||
assertThat(stats.getAnnotated()).isEqualTo(1);
|
||||
assertThat(stats.getTranscribed()).isEqualTo(1);
|
||||
assertThat(stats.getUploaded()).isEqualTo(1);
|
||||
assertThat(stats.getYourPages()).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw', 'Anna', 'Meier', '#f00')",
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
|
||||
})
|
||||
void findContributorsPerDocument_returnsContributorWithInitialsAndColor() {
|
||||
List<ContributorRow> rows = auditLogQueryRepository.findContributorsPerDocument(List.of(DOC_ID));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID);
|
||||
assertThat(rows.get(0).getActorInitials()).isEqualTo("AM");
|
||||
assertThat(rows.get(0).getActorColor()).isEqualTo("#f00");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(DashboardController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class DashboardControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean DashboardService dashboardService;
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── Security ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void resume_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/dashboard/resume"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void resume_returns403_whenUserHasNoPermissions() throws Exception {
|
||||
mockMvc.perform(get("/api/dashboard/resume"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pulse_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/dashboard/pulse"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void pulse_returns403_whenUserHasNoPermissions() throws Exception {
|
||||
mockMvc.perform(get("/api/dashboard/pulse"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// ─── GET /api/dashboard/resume ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void resume_returnsNull_whenNoInProgressTranscription() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(
|
||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
||||
when(dashboardService.getResume(userId)).thenReturn(null);
|
||||
|
||||
mockMvc.perform(get("/api/dashboard/resume"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void resume_returnsDocument_whenUserHasInProgressTranscription() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(
|
||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
||||
|
||||
DashboardResumeDTO resume = new DashboardResumeDTO(docId, "Brief an Frieda",
|
||||
"Frieda an Wilhelm · 1923", "Liebe Frieda…", 4, 38, null, List.of());
|
||||
when(dashboardService.getResume(userId)).thenReturn(resume);
|
||||
|
||||
mockMvc.perform(get("/api/dashboard/resume"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.documentId").value(docId.toString()))
|
||||
.andExpect(jsonPath("$.pct").value(38));
|
||||
}
|
||||
|
||||
// ─── GET /api/dashboard/pulse ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void pulse_returnsWeekStats() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(
|
||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
||||
|
||||
DashboardPulseDTO pulse = new DashboardPulseDTO(86, 23, 9, 47, 4, List.of());
|
||||
when(dashboardService.getPulse(userId)).thenReturn(pulse);
|
||||
|
||||
mockMvc.perform(get("/api/dashboard/pulse"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.pages").value(86))
|
||||
.andExpect(jsonPath("$.annotated").value(23));
|
||||
}
|
||||
|
||||
@Test
|
||||
void activity_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/dashboard/activity"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void activity_returns403_whenUserHasNoPermissions() throws Exception {
|
||||
mockMvc.perform(get("/api/dashboard/activity"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// ─── GET /api/dashboard/activity ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void activity_returnsDeduplicated_feed() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(
|
||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
||||
when(dashboardService.getActivity(any(UUID.class), anyInt())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/dashboard/activity"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DashboardServiceTest {
|
||||
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock TranscriptionService transcriptionService;
|
||||
@Mock UserService userService;
|
||||
|
||||
@InjectMocks DashboardService dashboardService;
|
||||
|
||||
// ─── toActorDTO (via getResume collaborators) ─────────────────────────────
|
||||
|
||||
@Test
|
||||
void getResume_collaboratorName_isNullSafe_whenFirstNameIsNull() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID collaboratorId = UUID.randomUUID();
|
||||
|
||||
Document doc = Document.builder()
|
||||
.id(docId).title("Brief").originalFilename("brief.pdf")
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
|
||||
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).annotationId(UUID.randomUUID())
|
||||
.documentId(docId).sortOrder(1).updatedBy(collaboratorId)
|
||||
.build();
|
||||
|
||||
AppUser collaborator = AppUser.builder()
|
||||
.id(collaboratorId).email("s@test.com").password("pw")
|
||||
.firstName(null).lastName("Schmidt").color("#abc")
|
||||
.build();
|
||||
|
||||
when(auditLogQueryService.findMostRecentDocumentForUser(userId)).thenReturn(Optional.of(docId));
|
||||
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
||||
when(transcriptionService.listBlocks(docId)).thenReturn(List.of(block));
|
||||
when(userService.getById(collaboratorId)).thenReturn(collaborator);
|
||||
|
||||
DashboardResumeDTO result = dashboardService.getResume(userId);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.collaborators()).hasSize(1);
|
||||
assertThat(result.collaborators().get(0).name()).isEqualTo("Schmidt");
|
||||
}
|
||||
|
||||
// ─── getActivity bulk-load ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getActivity_loadsDocumentTitles_withoutPerRowLookup() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "ANNOTATION_CREATED");
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row, row));
|
||||
|
||||
Document doc = Document.builder()
|
||||
.id(docId).title("Familienbrief").originalFilename("f.pdf")
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(doc));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
|
||||
|
||||
assertThat(items).hasSize(2);
|
||||
assertThat(items.get(0).documentTitle()).isEqualTo("Familienbrief");
|
||||
verify(documentService, never()).getDocumentById(docId);
|
||||
}
|
||||
|
||||
private ActivityFeedRow mockFeedRow(UUID docId, String kind) {
|
||||
return new ActivityFeedRow() {
|
||||
public String getKind() { return kind; }
|
||||
public UUID getActorId() { return null; }
|
||||
public String getActorInitials() { return ""; }
|
||||
public String getActorColor() { return ""; }
|
||||
public String getActorName() { return ""; }
|
||||
public UUID getDocumentId() { return docId; }
|
||||
public Instant getHappenedAt() { return Instant.now(); }
|
||||
public boolean isYouMentioned() { return false; }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AppUserTest {
|
||||
|
||||
private static final List<String> EXPECTED_PALETTE = List.of(
|
||||
"#7a4f9a", "#5a8a6a", "#3060b0", "#a0522d", "#c0446e", "#c17a00", "#0e7490", "#1d4ed8"
|
||||
);
|
||||
|
||||
@Test
|
||||
void computeColor_returnsDeterministicPaletteColor() {
|
||||
UUID id = UUID.fromString("12345678-1234-1234-1234-123456789abc");
|
||||
String color = AppUser.computeColor(id);
|
||||
assertThat(EXPECTED_PALETTE).contains(color);
|
||||
assertThat(AppUser.computeColor(id)).isEqualTo(color);
|
||||
}
|
||||
|
||||
@Test
|
||||
void computeColor_isStableAcrossCalls() {
|
||||
UUID id = UUID.randomUUID();
|
||||
assertThat(AppUser.computeColor(id)).isEqualTo(AppUser.computeColor(id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void computeColor_variesAcrossDifferentIds() {
|
||||
long distinct = java.util.stream.IntStream.range(0, 100)
|
||||
.mapToObj(i -> AppUser.computeColor(UUID.randomUUID()))
|
||||
.distinct()
|
||||
.count();
|
||||
assertThat(distinct).isGreaterThan(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.raddatz.familienarchiv.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SecurityUtilsTest {
|
||||
|
||||
@Mock Authentication authentication;
|
||||
@Mock UserService userService;
|
||||
|
||||
@Test
|
||||
void requireUserId_throwsUnauthorized_whenAuthenticationIsNull() {
|
||||
assertThatThrownBy(() -> SecurityUtils.requireUserId(null, userService))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireUserId_throwsUnauthorized_whenNotAuthenticated() {
|
||||
when(authentication.isAuthenticated()).thenReturn(false);
|
||||
assertThatThrownBy(() -> SecurityUtils.requireUserId(authentication, userService))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireUserId_throwsUnauthorized_whenUserNotFound() {
|
||||
when(authentication.isAuthenticated()).thenReturn(true);
|
||||
when(authentication.getName()).thenReturn("ghost@example.com");
|
||||
when(userService.findByEmail("ghost@example.com")).thenReturn(null);
|
||||
assertThatThrownBy(() -> SecurityUtils.requireUserId(authentication, userService))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireUserId_returnsUserId_whenAuthenticated() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(userId).email("user@example.com").password("pw").build();
|
||||
when(authentication.isAuthenticated()).thenReturn(true);
|
||||
when(authentication.getName()).thenReturn("user@example.com");
|
||||
when(userService.findByEmail("user@example.com")).thenReturn(user);
|
||||
|
||||
UUID result = SecurityUtils.requireUserId(authentication, userService);
|
||||
|
||||
assertThat(result).isEqualTo(userId);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
|
||||
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
@@ -13,17 +16,25 @@ import org.raddatz.familienarchiv.repository.TranscriptionWeeklyStatsProjection;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TranscriptionQueueServiceTest {
|
||||
|
||||
@Mock DocumentRepository documentRepository;
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@InjectMocks TranscriptionQueueService service;
|
||||
|
||||
@BeforeEach
|
||||
void stubContributors() {
|
||||
lenient().when(auditLogQueryService.findContributorsPerDocument(any())).thenReturn(Map.of());
|
||||
}
|
||||
|
||||
// ─── getSegmentationQueue ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -42,6 +53,38 @@ class TranscriptionQueueServiceTest {
|
||||
assertThat(result.get(0).annotationCount()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentationQueue_returnsEmptyList_whenQueueIsEmpty() {
|
||||
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of());
|
||||
|
||||
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
verifyNoInteractions(auditLogQueryService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentationQueue_returnsAllFive_andHasMoreFalse_whenExactlyFiveContributors() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0);
|
||||
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj));
|
||||
|
||||
List<ActivityActorDTO> fiveActors = List.of(
|
||||
new ActivityActorDTO("A1", "#111", "Alice One"),
|
||||
new ActivityActorDTO("A2", "#222", "Alice Two"),
|
||||
new ActivityActorDTO("A3", "#333", "Alice Three"),
|
||||
new ActivityActorDTO("A4", "#444", "Alice Four"),
|
||||
new ActivityActorDTO("A5", "#555", "Alice Five")
|
||||
);
|
||||
when(auditLogQueryService.findContributorsPerDocument(List.of(docId)))
|
||||
.thenReturn(Map.of(docId, fiveActors));
|
||||
|
||||
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
|
||||
|
||||
assertThat(result.get(0).contributors()).hasSize(5);
|
||||
assertThat(result.get(0).hasMoreContributors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentationQueue_mapsDocumentDateWhenPresent() {
|
||||
LocalDate date = LocalDate.of(1920, 6, 15);
|
||||
@@ -108,6 +151,47 @@ class TranscriptionQueueServiceTest {
|
||||
assertThat(result.transcriptionCount()).isEqualTo(0L);
|
||||
}
|
||||
|
||||
// ─── contributors ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getSegmentationQueue_includesContributors_whenAuditDataPresent() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0);
|
||||
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj));
|
||||
|
||||
ActivityActorDTO actor = new ActivityActorDTO("MR", "#a6dad8", "Max Raddatz");
|
||||
when(auditLogQueryService.findContributorsPerDocument(List.of(docId)))
|
||||
.thenReturn(Map.of(docId, List.of(actor)));
|
||||
|
||||
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
|
||||
|
||||
assertThat(result.get(0).contributors()).containsExactly(actor);
|
||||
assertThat(result.get(0).hasMoreContributors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentationQueue_capsContributorsAtFive_andSetsHasMoreFlag() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0);
|
||||
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj));
|
||||
|
||||
List<ActivityActorDTO> sixActors = List.of(
|
||||
new ActivityActorDTO("A1", "#111", "Alice One"),
|
||||
new ActivityActorDTO("A2", "#222", "Alice Two"),
|
||||
new ActivityActorDTO("A3", "#333", "Alice Three"),
|
||||
new ActivityActorDTO("A4", "#444", "Alice Four"),
|
||||
new ActivityActorDTO("A5", "#555", "Alice Five"),
|
||||
new ActivityActorDTO("A6", "#666", "Alice Six")
|
||||
);
|
||||
when(auditLogQueryService.findContributorsPerDocument(List.of(docId)))
|
||||
.thenReturn(Map.of(docId, sixActors));
|
||||
|
||||
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
|
||||
|
||||
assertThat(result.get(0).contributors()).hasSize(5);
|
||||
assertThat(result.get(0).hasMoreContributors()).isTrue();
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private TranscriptionQueueProjection mockQueueProjection(
|
||||
|
||||
@@ -487,6 +487,7 @@ class TranscriptionServiceTest {
|
||||
org.mockito.ArgumentMatchers.eq(docId),
|
||||
payloadCaptor.capture());
|
||||
assertThat(payloadCaptor.getValue()).containsEntry("pageNumber", 3);
|
||||
assertThat(payloadCaptor.getValue()).containsEntry("blockId", blockId.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
665
docs/specs/documents-page-spec.html
Normal file
665
docs/specs/documents-page-spec.html
Normal file
@@ -0,0 +1,665 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Dokumente-Seite — Design Spec</title>
|
||||
<style>
|
||||
:root{
|
||||
--navy:#002850;--mint:#A6DAD8;--sand:#E4E2D7;
|
||||
--surface:#FAFAF7;--bg:#E8E7E2;--border:#D8D7D0;
|
||||
--text:#1C1C18;--muted:#6B6A63;--subtle:#9B9A93;
|
||||
--orange:#C26A00;--orange-bg:#FEF4E2;
|
||||
--green:#2E6E39;--green-bg:#EAF5EA;
|
||||
--purple:#5B5EA6;--purple-bg:#EEEDFE;
|
||||
--font:system-ui,sans-serif;--mono:'Courier New',monospace;
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;}
|
||||
.doc{max-width:1100px;margin:0 auto;padding:48px 32px 96px;}
|
||||
hr{border:none;border-top:1px solid var(--border);margin:48px 0;}
|
||||
|
||||
/* Header */
|
||||
.hdr{background:var(--navy);color:#fff;padding:32px 32px 28px;border-radius:8px 8px 0 0;}
|
||||
.hdr h1{font-family:Georgia,serif;font-size:26px;font-weight:400;letter-spacing:-.02em;margin-bottom:8px;}
|
||||
.hdr-meta{font-family:var(--mono);font-size:11px;color:rgba(255,255,255,.45);margin-top:10px;}
|
||||
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;letter-spacing:.05em;background:var(--mint);color:var(--navy);}
|
||||
.badge-g{background:rgba(255,255,255,.15);color:rgba(255,255,255,.9);}
|
||||
.badges{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}
|
||||
.decision-box{background:#fff;border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;padding:20px 28px 24px;margin-bottom:40px;}
|
||||
.decision-box h2{font-family:Georgia,serif;font-size:16px;font-weight:400;color:var(--navy);margin-bottom:8px;}
|
||||
.prose{font-size:13px;color:var(--muted);line-height:1.65;max-width:720px;margin-bottom:10px;}
|
||||
.prose:last-child{margin-bottom:0;}
|
||||
|
||||
/* Sections */
|
||||
.sec{margin-bottom:52px;}
|
||||
.sec-label{font-size:10px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);margin-bottom:22px;}
|
||||
.sec-title{font-family:Georgia,serif;font-size:20px;font-weight:400;color:var(--navy);margin-bottom:4px;}
|
||||
.sec-sub{font-size:13px;color:var(--muted);margin-bottom:16px;}
|
||||
|
||||
/* Callout */
|
||||
.callout{display:flex;gap:12px;padding:14px 16px;border-radius:4px;margin-bottom:16px;font-size:12px;line-height:1.55;}
|
||||
.callout.orange{background:var(--orange-bg);border-left:3px solid var(--orange);}
|
||||
.callout.green{background:var(--green-bg);border-left:3px solid var(--green);}
|
||||
.callout.navy{background:rgba(0,40,80,.05);border-left:3px solid var(--navy);}
|
||||
.callout.purple{background:var(--purple-bg);border-left:3px solid var(--purple);}
|
||||
.callout strong{font-weight:700;}
|
||||
.callout strong.o{color:var(--orange);}
|
||||
.callout strong.g{color:var(--green);}
|
||||
.callout strong.n{color:var(--navy);}
|
||||
|
||||
/* impl-ref */
|
||||
.impl-ref{margin-top:20px;}
|
||||
.impl-ref table{width:100%;border-collapse:collapse;font-size:12px;}
|
||||
.impl-ref th{background:var(--navy);color:#fff;padding:6px 10px;text-align:left;font-size:10px;font-weight:600;letter-spacing:.06em;}
|
||||
.impl-ref td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top;line-height:1.6;}
|
||||
.impl-ref tr:nth-child(even) td{background:var(--surface);}
|
||||
.impl-ref code{font-family:var(--mono);font-size:11px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;}
|
||||
.impl-ref .new{color:var(--green);font-weight:600;}
|
||||
.impl-ref .changed{color:var(--orange);font-weight:600;}
|
||||
|
||||
/* caption */
|
||||
.caption{font-family:var(--mono);font-size:10px;color:var(--muted);display:block;margin-top:6px;}
|
||||
|
||||
/* Two-col layout */
|
||||
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px;}
|
||||
.three-col{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:24px;}
|
||||
|
||||
/* ─── MOCKUP FRAME ───────────────────────────── */
|
||||
/* Scaled at ~56% (640px wide renders as ~1140px concept) */
|
||||
.frame-wrap{background:var(--surface);border:1px solid var(--border);border-radius:6px;overflow:hidden;box-shadow:0 4px 16px rgba(0,0,0,.08);margin-bottom:8px;}
|
||||
|
||||
/* Topbar */
|
||||
.f-topbar{background:var(--navy);height:26px;display:flex;align-items:center;padding:0 14px;gap:12px;}
|
||||
.f-logo{font-size:6.5px;font-weight:700;color:#fff;letter-spacing:.7px;}
|
||||
.f-navlinks{display:flex;gap:8px;margin-left:6px;}
|
||||
.f-navlink{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:600;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.f-navlink.on{color:rgba(255,255,255,.9);border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
|
||||
.f-navr{margin-left:auto;display:flex;align-items:center;gap:4px;}
|
||||
.f-uname{font-size:5.5px;color:rgba(255,255,255,.4);text-transform:uppercase;font-weight:600;}
|
||||
.f-av{width:14px;height:14px;border-radius:50%;background:var(--mint);display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--navy);}
|
||||
|
||||
/* Search bar */
|
||||
.f-searchbar{background:#fff;border-bottom:1px solid var(--sand);padding:7px 14px;display:flex;align-items:center;gap:8px;}
|
||||
.f-search-input{flex:1;height:18px;border:1.5px solid var(--navy);border-radius:2px;padding:0 6px;display:flex;align-items:center;gap:4px;}
|
||||
.f-search-q{font-size:6.5px;color:var(--navy);font-weight:600;}
|
||||
.f-search-count{font-size:5.5px;font-weight:600;color:var(--subtle);text-transform:uppercase;letter-spacing:.06em;white-space:nowrap;}
|
||||
.f-newbtn{height:18px;padding:0 7px;background:var(--navy);border-radius:2px;font-size:5.5px;font-weight:700;color:#fff;text-transform:uppercase;letter-spacing:.05em;display:flex;align-items:center;}
|
||||
|
||||
/* Sort bar */
|
||||
.f-sortbar{background:#fff;border-bottom:1px solid var(--sand);padding:5px 14px;display:flex;align-items:center;gap:8px;}
|
||||
.f-sortcount{flex:1;font-size:5.5px;color:var(--subtle);}
|
||||
.f-sortlabel{font-size:5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--subtle);}
|
||||
.f-sortsel{height:14px;border:1px solid var(--sand);border-radius:2px;padding:0 5px;font-size:5.5px;color:var(--navy);background:var(--sand);display:flex;align-items:center;}
|
||||
.f-filterbtn{height:14px;padding:0 6px;background:var(--navy);border-radius:2px;font-size:5px;font-weight:700;color:#fff;text-transform:uppercase;display:flex;align-items:center;gap:3px;}
|
||||
.f-fbadge{background:var(--mint);color:var(--navy);font-size:4.5px;font-weight:800;border-radius:8px;padding:0 3px;}
|
||||
|
||||
/* Filter panel (open) */
|
||||
.f-filterpanel{background:#fff;border-bottom:1px solid var(--sand);padding:8px 14px;display:flex;gap:16px;}
|
||||
.f-fpgroup{flex:1;}
|
||||
.f-fplabel{font-size:4.5px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--subtle);margin-bottom:4px;display:block;}
|
||||
.f-fpinput{height:12px;border:1px solid var(--sand);border-radius:2px;padding:0 5px;font-size:5px;color:var(--subtle);background:var(--sand);width:100%;display:flex;align-items:center;}
|
||||
.f-fpdate{display:flex;gap:3px;}
|
||||
.f-fpdate .f-fpinput{flex:1;}
|
||||
.f-fptag{font-size:4.5px;font-weight:700;background:var(--mint);color:var(--navy);border-radius:2px;padding:1px 5px;display:inline-block;margin-right:2px;margin-bottom:2px;}
|
||||
.f-fptag-e{font-size:4.5px;font-weight:700;background:var(--sand);color:var(--subtle);border-radius:2px;padding:1px 5px;display:inline-block;margin-right:2px;margin-bottom:2px;}
|
||||
|
||||
/* List body */
|
||||
.f-body{padding:8px 14px;}
|
||||
|
||||
/* Year card */
|
||||
.f-yearcard{border:1px solid var(--border);background:#fff;margin-bottom:8px;overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,.04);}
|
||||
.f-yearhead{background:var(--sand);padding:3px 10px;font-size:5.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--subtle);border-bottom:1px solid var(--border);}
|
||||
|
||||
/* Document row */
|
||||
.f-docrow{display:flex;border-bottom:1px solid #ece9e0;}
|
||||
.f-docrow:last-child{border-bottom:none;}
|
||||
.f-docrow:hover{background:#fafaf8;}
|
||||
.f-docleft{flex:1;min-width:0;padding:8px 10px;border-right:1px solid #ece9e0;}
|
||||
.f-docright{width:110px;flex-shrink:0;padding:6px 9px;display:flex;flex-direction:column;justify-content:space-between;}
|
||||
.f-doctitle{font-family:Georgia,serif;font-size:7px;font-weight:700;color:var(--navy);margin-bottom:3px;line-height:1.3;}
|
||||
.f-doctitle .hl{border-bottom:1.5px solid var(--navy);}
|
||||
.f-docsnip{font-family:Georgia,serif;font-size:5.5px;color:#4b5563;font-style:italic;line-height:1.5;margin-bottom:4px;}
|
||||
.f-docsnip .hl{border-bottom:1.5px solid var(--navy);}
|
||||
.f-doctags{display:flex;gap:2px;flex-wrap:wrap;}
|
||||
.f-doctag{font-size:4.5px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;background:#dbeafe;color:var(--navy);border-radius:1px;padding:1px 4px;display:flex;align-items:center;gap:2px;}
|
||||
.f-doctag .dot{width:4px;height:4px;border-radius:50%;background:#3b82f6;flex-shrink:0;}
|
||||
.f-doctag.fam{background:#dcfce7;}.f-doctag.fam .dot{background:#16a34a;}
|
||||
.f-ml{display:flex;align-items:center;gap:3px;font-size:5px;color:var(--muted);margin-bottom:2px;}
|
||||
.f-ml strong{font-size:4.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--navy);}
|
||||
.f-meta-bottom{display:flex;align-items:center;justify-content:space-between;gap:4px;margin-top:4px;}
|
||||
/* Ring */
|
||||
.f-ring{position:relative;width:20px;height:20px;flex-shrink:0;}
|
||||
.f-ring svg{position:absolute;top:0;left:0;transform:rotate(-90deg);}
|
||||
.f-ring-label{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:4px;font-weight:800;}
|
||||
/* Contributors */
|
||||
.f-contribs{display:flex;}
|
||||
.f-cav{width:12px;height:12px;border-radius:50%;border:1.5px solid white;display:flex;align-items:center;justify-content:center;font-size:4px;font-weight:800;color:#fff;margin-left:-3px;}
|
||||
.f-contribs .f-cav:first-child{margin-left:0;}
|
||||
|
||||
/* annotation box */
|
||||
.anno-box{background:#fff;border:1px solid var(--border);border-radius:6px;padding:16px 20px;margin-bottom:16px;}
|
||||
.anno-box h4{font-size:12px;font-weight:700;color:var(--navy);margin-bottom:6px;}
|
||||
.anno-box p{font-size:12px;color:var(--muted);line-height:1.55;margin-bottom:8px;}
|
||||
.anno-box p:last-child{margin-bottom:0;}
|
||||
.anno-box code{font-family:var(--mono);font-size:10px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
HEADER
|
||||
══════════════════════════════════════ -->
|
||||
<div class="hdr">
|
||||
<div class="badges">
|
||||
<span class="badge">Neue Route</span>
|
||||
<span class="badge badge-g">Frontend</span>
|
||||
<span class="badge badge-g">Backend</span>
|
||||
</div>
|
||||
<h1>Dokumente-Seite — /documents</h1>
|
||||
<p style="font-size:13px;color:rgba(255,255,255,.6);margin-top:6px;max-width:680px;">
|
||||
Dedicated search and browse page for all documents. Separates the document list from the dashboard hub. Uses per-year group cards with flat divide-y rows, a horizontal split row (content left · metadata right), a circular progress ring, and contributor avatars.
|
||||
</p>
|
||||
<div class="hdr-meta">Spec · Leonie Voss · 2026-04-19 · Issue TBD</div>
|
||||
</div>
|
||||
|
||||
<div class="decision-box">
|
||||
<h2>Design decisions</h2>
|
||||
<p class="prose">The hub (<code>/</code>) becomes pure dashboard — no more dual-mode switching. The "Documents" nav tab points to <code>/documents</code>, a focused search/browse page.</p>
|
||||
<p class="prose">Row layout: two-column split — title and snippet occupy the full left column for maximum scan width; date, sender, receiver, archive location, progress ring and contributor avatars live in a fixed 240px right panel. This keeps metadata consistently positioned across all rows.</p>
|
||||
<p class="prose">List structure: one white card container per year group (matching the current <code>border border-line bg-surface shadow-sm</code> pattern), rows separated by <code>divide-y</code> dividers — no gaps, no individual row cards. The year label is an inset header row within each card.</p>
|
||||
<p class="prose">Progress ring shows work completion as a percentage (0–100%). It is driven by a new <code>completionPercentage</code> field on the search result DTO, computed server-side from annotation block counts. Contributor avatars require a new <code>contributors</code> array (initials + color) on the search DTO.</p>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
SECTION 1 — FULL MOCKUP
|
||||
══════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Section 1</div>
|
||||
<div class="sec-title">Full page mockup — filter panel open, search active</div>
|
||||
<div class="sec-sub">Scaled at ~56%. Desktop 1200px concept width.</div>
|
||||
|
||||
<div class="frame-wrap">
|
||||
<!-- Topbar -->
|
||||
<div class="f-topbar">
|
||||
<div class="f-logo">FAMILIENARCHIV</div>
|
||||
<div class="f-navlinks">
|
||||
<span class="f-navlink on">Documents</span>
|
||||
<span class="f-navlink">Persons</span>
|
||||
<span class="f-navlink">Letters</span>
|
||||
<span class="f-navlink">Admin</span>
|
||||
</div>
|
||||
<div class="f-navr"><span class="f-uname">Hochlader</span><div class="f-av">MR</div></div>
|
||||
</div>
|
||||
<!-- Search bar -->
|
||||
<div class="f-searchbar">
|
||||
<div class="f-search-input">
|
||||
<svg width="8" height="8" viewBox="0 0 20 20" fill="none"><circle cx="8.5" cy="8.5" r="5.75" stroke="#9ca3af" stroke-width="1.5"/><path d="M13 13l3.5 3.5" stroke="#9ca3af" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
<span class="f-search-q">brief</span>
|
||||
</div>
|
||||
<span class="f-search-count">31 Dokumente</span>
|
||||
<div class="f-newbtn">+ New Document</div>
|
||||
</div>
|
||||
<!-- Sort bar -->
|
||||
<div class="f-sortbar">
|
||||
<span class="f-sortcount">31 documents</span>
|
||||
<span class="f-sortlabel">Sort</span>
|
||||
<div class="f-sortsel">Date ↓</div>
|
||||
<div class="f-filterbtn">
|
||||
<svg width="7" height="7" viewBox="0 0 16 16" fill="none"><path d="M2 4h12M4 8h8M6 12h4" stroke="white" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Filters <span class="f-fbadge">1</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filter panel -->
|
||||
<div class="f-filterpanel">
|
||||
<div class="f-fpgroup">
|
||||
<span class="f-fplabel">Date range</span>
|
||||
<div class="f-fpdate"><div class="f-fpinput">From</div><div class="f-fpinput">To</div></div>
|
||||
</div>
|
||||
<div class="f-fpgroup">
|
||||
<span class="f-fplabel">Sender</span>
|
||||
<div class="f-fpinput">Search person…</div>
|
||||
</div>
|
||||
<div class="f-fpgroup">
|
||||
<span class="f-fplabel">Receiver</span>
|
||||
<div class="f-fpinput">Search person…</div>
|
||||
</div>
|
||||
<div class="f-fpgroup">
|
||||
<span class="f-fplabel">Tags</span>
|
||||
<span class="f-fptag">Brief</span><span class="f-fptag-e">Foto</span><span class="f-fptag-e">Postkarte</span><span class="f-fptag-e">Urkunde</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- List body -->
|
||||
<div class="f-body">
|
||||
|
||||
<!-- 1924 card -->
|
||||
<div class="f-yearcard">
|
||||
<div class="f-yearhead">1924</div>
|
||||
<div class="f-docrow">
|
||||
<div class="f-docleft">
|
||||
<div class="f-doctitle">Demo: Ierlicher <span class="hl">Brief</span> — Belgern</div>
|
||||
<div class="f-docsnip">… Hiermit übersende ich Ihnen den gewünschten <span class="hl">Brief</span> meines Vaters, welcher einige interessante Hinweise zur Familiengeschichte enthält …</div>
|
||||
<div class="f-doctags"><div class="f-doctag"><div class="dot"></div>Brief</div><div class="f-doctag fam"><div class="dot"></div>Familie</div></div>
|
||||
</div>
|
||||
<div class="f-docright">
|
||||
<div>
|
||||
<div class="f-ml"><strong>Date</strong> 31. Mai 1924</div>
|
||||
<div class="f-ml"><strong>From</strong> Louise Aon Boden</div>
|
||||
<div class="f-ml"><strong>To</strong> Marcel Raddatz</div>
|
||||
<div class="f-ml"><strong>Archive</strong> Box 3 · Folder A</div>
|
||||
</div>
|
||||
<div class="f-meta-bottom">
|
||||
<div class="f-ring">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="none" stroke="#E4E2D7" stroke-width="2"/><circle cx="10" cy="10" r="7" fill="none" stroke="#A6DAD8" stroke-width="2" stroke-dasharray="44 44" stroke-linecap="round"/></svg>
|
||||
<div class="f-ring-label" style="color:#5bbab7">100%</div>
|
||||
</div>
|
||||
<div class="f-contribs"><div class="f-cav" style="background:#7c3aed">MR</div><div class="f-cav" style="background:#0891b2">LS</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1923 card -->
|
||||
<div class="f-yearcard">
|
||||
<div class="f-yearhead">1923</div>
|
||||
<div class="f-docrow">
|
||||
<div class="f-docleft">
|
||||
<div class="f-doctitle">W-0614 – 8. September 1923 – Tölz</div>
|
||||
<div class="f-docsnip">… Clara schreibt über die Ankunft in Tölz und erwähnt den letzten <span class="hl">Brief</span> von Fauld Rupley, der noch keine Antwort erhalten hat …</div>
|
||||
<div class="f-doctags"><div class="f-doctag"><div class="dot"></div>Brief</div></div>
|
||||
</div>
|
||||
<div class="f-docright">
|
||||
<div>
|
||||
<div class="f-ml"><strong>Date</strong> 8. Sept. 1923</div>
|
||||
<div class="f-ml"><strong>From</strong> Clara Lam</div>
|
||||
<div class="f-ml"><strong>To</strong> Fauld Rupley</div>
|
||||
<div class="f-ml"><strong>Archive</strong> Box 1 · Folder C</div>
|
||||
</div>
|
||||
<div class="f-meta-bottom">
|
||||
<div class="f-ring">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="none" stroke="#E4E2D7" stroke-width="2"/><circle cx="10" cy="10" r="7" fill="none" stroke="#A6DAD8" stroke-width="2" stroke-dasharray="33 44" stroke-linecap="round"/></svg>
|
||||
<div class="f-ring-label" style="color:#5bbab7">75%</div>
|
||||
</div>
|
||||
<div class="f-contribs"><div class="f-cav" style="background:#dc2626">AK</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f-docrow">
|
||||
<div class="f-docleft">
|
||||
<div class="f-doctitle">W-0196 – 2. September 1923 – B. Lichterfelde</div>
|
||||
<div class="f-docsnip">… Prediger's Haushaltung enthält einen <span class="hl">Brief</span>; Zusammen mit der Vollmacht aus dem Vorjahr ergibt sich folgendes Bild …</div>
|
||||
<div class="f-doctags"><div class="f-doctag"><div class="dot"></div>Brief</div></div>
|
||||
</div>
|
||||
<div class="f-docright">
|
||||
<div>
|
||||
<div class="f-ml"><strong>Date</strong> 2. Sept. 1923</div>
|
||||
<div class="f-ml"><strong>From</strong> Müller de Gruym</div>
|
||||
<div class="f-ml"><strong>To</strong> Herbert Cram</div>
|
||||
</div>
|
||||
<div class="f-meta-bottom">
|
||||
<div class="f-ring">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="none" stroke="#E4E2D7" stroke-width="2"/><circle cx="10" cy="10" r="7" fill="none" stroke="#A6DAD8" stroke-width="2" stroke-dasharray="18 44" stroke-linecap="round"/></svg>
|
||||
<div class="f-ring-label" style="color:#9ca3af">40%</div>
|
||||
</div>
|
||||
<div class="f-contribs"><div class="f-cav" style="background:#7c3aed">MR</div><div class="f-cav" style="background:#0891b2">LS</div><div class="f-cav" style="background:#dc2626">AK</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f-docrow">
|
||||
<div class="f-docleft">
|
||||
<div class="f-doctitle">W-0397 – 2. September 1923 – B. Lichterfelde</div>
|
||||
<div class="f-docsnip">… zum einleitend Kommentar hieraus, den Herrn, zum <span class="hl">Brief</span> az sechzig und weitere Passagen …</div>
|
||||
<div class="f-doctags"><div class="f-doctag"><div class="dot"></div>Brief</div></div>
|
||||
</div>
|
||||
<div class="f-docright">
|
||||
<div>
|
||||
<div class="f-ml"><strong>Date</strong> 2. Sept. 1923</div>
|
||||
<div class="f-ml"><strong>From</strong> Müller de Gruym</div>
|
||||
</div>
|
||||
<div class="f-meta-bottom">
|
||||
<div class="f-ring">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="none" stroke="#E4E2D7" stroke-width="2"/></svg>
|
||||
<div class="f-ring-label" style="color:#9ca3af">0%</div>
|
||||
</div>
|
||||
<span style="font-size:4.5px;color:#9ca3af;font-weight:600;text-transform:uppercase;letter-spacing:.08em;">No contributors</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<span class="caption">Fig 1 — /documents · 1200px · search: "brief" · filter panel open · sort: Date ↓</span>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
SECTION 2 — PAGE STRUCTURE
|
||||
══════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Section 2</div>
|
||||
<div class="sec-title">Page structure & zones</div>
|
||||
|
||||
<div class="three-col">
|
||||
<div class="anno-box">
|
||||
<h4>① Global search bar</h4>
|
||||
<p>Full-width row below the topbar. Contains the search input (flex-1), result count (right of input), and "+ New Document" button. Background white, bottom border <code>border-line</code>. Sticky — stays visible on scroll.</p>
|
||||
<p>Same search bar pattern as the current homepage. Debounce 500 ms on text input; immediate on clear.</p>
|
||||
</div>
|
||||
<div class="anno-box">
|
||||
<h4>② Sort / count bar</h4>
|
||||
<p>Slim bar below search. Shows result count (left), sort dropdown (right), and Filters toggle button (far right). Background white, bottom border <code>border-line</code>. Sticky — stacks below search bar on scroll.</p>
|
||||
<p>Filters button shows a mint badge with active filter count. When filters are open the button fills navy.</p>
|
||||
</div>
|
||||
<div class="anno-box">
|
||||
<h4>③ Collapsible filter panel</h4>
|
||||
<p>Drops open below the sort bar. Contains four groups: Date range (two inputs), Sender (PersonTypeahead), Receiver (PersonTypeahead), Tags (clickable pills). White background, bottom border <code>border-line</code>.</p>
|
||||
<p>Closed by default on page load unless URL already has active filter params. Animate open/close with <code>transition-all duration-200</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout navy">
|
||||
<div><strong class="n">Routing:</strong> New route <code>frontend/src/routes/documents/+page.svelte</code> and <code>+page.server.ts</code>. AppNav "Documents" tab <code>href</code> changes from <code>/</code> to <code>/documents</code>. Homepage <code>+page.svelte</code> loses dual-mode — always renders dashboard. No redirect from <code>/?q=…</code>.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
SECTION 2b — MOBILE BREAKPOINTS
|
||||
══════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Section 2b</div>
|
||||
<div class="sec-title">Mobile breakpoints</div>
|
||||
<div class="sec-sub">Three responsive tiers: <sm (mobile), sm–lg (tablet), lg+ (desktop).</div>
|
||||
|
||||
<div class="three-col">
|
||||
<div class="anno-box">
|
||||
<h4>< sm — < 640px (mobile)</h4>
|
||||
<p>Document row: single-column block. Left/right split collapses. Metadata (date, from, to, archive) moves below the tags row as a 2×2 compact grid. Progress ring and contributor stack appear in a bottom row directly below the grid.</p>
|
||||
<p>Filter panel: single-column stack (<code>flex-col</code>). Sort bar wraps if needed.</p>
|
||||
</div>
|
||||
<div class="anno-box">
|
||||
<h4>sm – lg — 640–1023px</h4>
|
||||
<p>Document row: two-column split restored. Metadata column narrower: <code>sm:w-48</code> (192px) instead of <code>w-60</code> to fit tablet viewports.</p>
|
||||
<p>Sticky bars span full width via negative margins. Filter panel: <code>flex-row flex-wrap</code>, groups can wrap.</p>
|
||||
</div>
|
||||
<div class="anno-box">
|
||||
<h4>lg+ — ≥ 1024px (desktop)</h4>
|
||||
<p>Full two-column split. Metadata column: <code>lg:w-60</code> (240px). Filter panel: four groups in a single row. Max content width <code>max-w-7xl</code> (1280px) — from app layout container, no extra padding on list body.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile mockup at ~375px concept width, rendered at ~220px -->
|
||||
<div class="two-col">
|
||||
<div>
|
||||
<div class="frame-wrap" style="width:220px;">
|
||||
<div class="f-topbar" style="height:20px;padding:0 8px;gap:6px;">
|
||||
<span class="f-logo" style="font-size:5px;">FAMILIENARCHIV</span>
|
||||
<div style="margin-left:auto;display:flex;gap:6px;align-items:center;">
|
||||
<span class="f-navlink on" style="font-size:4.5px;border-bottom-width:1px;">Dokumente</span>
|
||||
<div class="f-av" style="width:12px;height:12px;font-size:4px;">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f-searchbar" style="padding:5px 8px;gap:5px;">
|
||||
<div class="f-search-input" style="height:14px;padding:0 5px;">
|
||||
<svg width="6" height="6" viewBox="0 0 20 20" fill="none"><circle cx="8.5" cy="8.5" r="5.75" stroke="#9ca3af" stroke-width="1.5"/><path d="M13 13l3.5 3.5" stroke="#9ca3af" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
<span style="font-size:5px;color:#002850;font-weight:600;">brief</span>
|
||||
</div>
|
||||
<span style="font-size:4.5px;color:#9B9A93;white-space:nowrap;font-weight:700;text-transform:uppercase;letter-spacing:.06em;">31 Dok.</span>
|
||||
</div>
|
||||
<div class="f-sortbar" style="padding:3px 8px;gap:5px;">
|
||||
<span class="f-sortcount">31 documents</span>
|
||||
<span class="f-sortlabel">Sort</span>
|
||||
<div class="f-sortsel" style="height:11px;font-size:4.5px;padding:0 4px;">Date ↓</div>
|
||||
<div class="f-filterbtn" style="height:11px;font-size:4px;padding:0 4px;">Filters</div>
|
||||
</div>
|
||||
<div style="padding:5px 8px;">
|
||||
<div class="f-yearcard">
|
||||
<div class="f-yearhead">1924</div>
|
||||
<!-- mobile stacked row 1 -->
|
||||
<div style="padding:7px 8px;border-bottom:1px solid #ece9e0;">
|
||||
<div class="f-doctitle" style="margin-bottom:2px;">Demo: Ierlicher <span class="hl">Brief</span> — Belgern</div>
|
||||
<div class="f-docsnip" style="margin-bottom:3px;">… Hiermit übersende ich Ihnen den gewünschten <span class="hl">Brief</span> …</div>
|
||||
<div class="f-doctags" style="margin-bottom:0;"><div class="f-doctag"><div class="dot"></div>Brief</div></div>
|
||||
<div style="border-top:1px solid #ece9e0;margin-top:4px;padding-top:3px;display:grid;grid-template-columns:1fr 1fr;gap:0 8px;">
|
||||
<div class="f-ml"><strong>Date</strong> 31. Mai 1924</div>
|
||||
<div class="f-ml"><strong>From</strong> L. von Boden</div>
|
||||
<div class="f-ml"><strong>Archive</strong> Box 3 · A</div>
|
||||
<div class="f-ml"><strong>To</strong> M. Raddatz</div>
|
||||
</div>
|
||||
<div class="f-meta-bottom" style="margin-top:4px;">
|
||||
<div class="f-ring">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="none" stroke="#E4E2D7" stroke-width="2"/><circle cx="10" cy="10" r="7" fill="none" stroke="#A6DAD8" stroke-width="2" stroke-dasharray="44 44" stroke-linecap="round"/></svg>
|
||||
<div class="f-ring-label" style="color:#5bbab7">100%</div>
|
||||
</div>
|
||||
<div class="f-contribs"><div class="f-cav" style="background:#7c3aed">MR</div><div class="f-cav" style="background:#0891b2">LS</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- mobile stacked row 2 -->
|
||||
<div style="padding:7px 8px;">
|
||||
<div class="f-doctitle" style="margin-bottom:2px;">W-0614 – Sept. 1923 – Tölz</div>
|
||||
<div class="f-docsnip" style="margin-bottom:3px;">… Clara schreibt über den letzten <span class="hl">Brief</span> von Fauld Rupley …</div>
|
||||
<div class="f-doctags" style="margin-bottom:0;"><div class="f-doctag"><div class="dot"></div>Brief</div></div>
|
||||
<div style="border-top:1px solid #ece9e0;margin-top:4px;padding-top:3px;display:grid;grid-template-columns:1fr 1fr;gap:0 8px;">
|
||||
<div class="f-ml"><strong>Date</strong> 8. Sept. 1923</div>
|
||||
<div class="f-ml"><strong>From</strong> Clara Lam</div>
|
||||
<div class="f-ml"></div>
|
||||
<div class="f-ml"><strong>To</strong> F. Rupley</div>
|
||||
</div>
|
||||
<div class="f-meta-bottom" style="margin-top:4px;">
|
||||
<div class="f-ring">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="none" stroke="#E4E2D7" stroke-width="2"/><circle cx="10" cy="10" r="7" fill="none" stroke="#A6DAD8" stroke-width="2" stroke-dasharray="33 44" stroke-linecap="round"/></svg>
|
||||
<div class="f-ring-label" style="color:#5bbab7">75%</div>
|
||||
</div>
|
||||
<div class="f-contribs"><div class="f-cav" style="background:#dc2626">AK</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="caption">Fig 2 — /documents · 375px mobile · search "brief" · filter closed</span>
|
||||
</div>
|
||||
|
||||
<div class="anno-box" style="align-self:start">
|
||||
<h4>Mobile row — CSS-only approach</h4>
|
||||
<p>No JS needed. The <code><a></code> link is always <code>block</code>. On <code>sm+</code> the inner element switches to <code>flex items-stretch</code>, showing the right metadata column (<code>hidden sm:flex</code>) and hiding the mobile compact grid (<code>sm:hidden</code>).</p>
|
||||
<p>This means the DOM contains both layouts simultaneously — the metadata grid inside the left column (mobile only) and the right metadata panel (sm+ only). Both share the same data, just rendered differently.</p>
|
||||
<p>Minimum touch target: the entire row is the <code><a></code>, guaranteed ≥44px on mobile given title + snippet + tags + metadata grid.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
SECTION 3 — YEAR GROUP CARD
|
||||
══════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Section 3</div>
|
||||
<div class="sec-title">Year group card</div>
|
||||
<div class="sec-sub">One card per year group. Rows inside use divide-y — no gaps between rows.</div>
|
||||
|
||||
<div class="two-col">
|
||||
<div class="anno-box">
|
||||
<h4>Card container</h4>
|
||||
<p>Matches current DocumentList outer container exactly: <code>border border-line bg-surface shadow-sm</code>. No border-radius (keeps it flush). Margin between consecutive year cards: <code>mb-4</code>.</p>
|
||||
<p>Rendered only when sort = DATE. For other sort modes (SENDER, RECEIVER, TITLE) the year header is replaced by the relevant group label using the same card pattern.</p>
|
||||
</div>
|
||||
<div class="anno-box">
|
||||
<h4>Year header row</h4>
|
||||
<p>First child of each card. Background <code>bg-sand</code>, text <code>text-xs font-bold uppercase tracking-widest text-ink-3</code>. Height <code>py-1.5 px-5</code>. Bottom border <code>border-b border-line</code>.</p>
|
||||
<p>Not a standalone divider — it is part of the card so the top border of the card frames the year label on three sides.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
SECTION 4 — DOCUMENT ROW
|
||||
══════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Section 4</div>
|
||||
<div class="sec-title">Document row — two-column split</div>
|
||||
|
||||
<div class="two-col">
|
||||
<div class="anno-box">
|
||||
<h4>Left column — content</h4>
|
||||
<p>Flex-1, min-width 0. Padding <code>p-4 pr-5</code>. Right border <code>border-r border-line-2</code>.</p>
|
||||
<p><strong>Title</strong> — <code>font-serif text-base font-bold text-ink</code> with search highlight underlines. <code>mb-1.5</code>.</p>
|
||||
<p><strong>Snippet</strong> — <code>font-serif text-sm italic text-ink-2 line-clamp-2 mb-2</code> with highlight underlines. Only rendered when a match snippet is present.</p>
|
||||
<p><strong>Tags</strong> — existing tag pill pattern <code>bg-muted text-ink text-[10px] font-bold uppercase tracking-widest rounded px-2 py-0.5</code>. Gap <code>gap-1.5 flex-wrap</code>.</p>
|
||||
</div>
|
||||
<div class="anno-box">
|
||||
<h4>Right column — metadata panel</h4>
|
||||
<p>Fixed width <code>w-60</code> (240px). Padding <code>p-3.5</code>. Flex column, <code>justify-between</code>.</p>
|
||||
<p><strong>Meta lines</strong> (top group) — <code>font-sans text-[11px] text-ink-2 mb-1</code>. Label: <code>font-bold uppercase tracking-wide text-[10px] text-ink-3 mr-1.5</code>. Lines: Date · From · To · Archive (Box · Folder). Archive only rendered when <code>archiveBox</code> is set.</p>
|
||||
<p><strong>Bottom row</strong> — flexbox, space-between. Left: progress ring. Right: ContributorStack.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout orange">
|
||||
<div><strong class="o">Accessibility:</strong> The ring conveys progress by both percentage text and arc fill — not colour alone. Contributors show initials as text inside the avatar. Both pass the redundant-cue requirement from the Leonie Voss persona. Minimum touch target for the row link: the full row is the <code><a></code> element, always ≥44px tall given the content. Row hover: <code>hover:bg-muted/50 transition-colors duration-200</code>.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
SECTION 5 — PROGRESS RING
|
||||
══════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Section 5</div>
|
||||
<div class="sec-title">Progress ring</div>
|
||||
|
||||
<div class="two-col">
|
||||
<div class="anno-box">
|
||||
<h4>Anatomy</h4>
|
||||
<p>SVG donut ring, 36×36px. Track circle: <code>stroke="#E4E2D7"</code> (<code>stroke-brand-sand</code>) width 3px. Fill arc: <code>stroke="#A6DAD8"</code> (<code>stroke-accent</code>) width 3px, <code>stroke-linecap="round"</code>. Rotated −90° so arc starts at 12 o'clock.</p>
|
||||
<p>Centre label: percentage text <code>font-sans text-[8px] font-bold</code>. Colour: mint (<code>text-accent-dark</code>) when >0%, gray-400 when 0%.</p>
|
||||
<p>Circumference of r=13: <code>2π×13 ≈ 81.7px</code>. Stroke-dasharray: <code>{pct * 81.7} 81.7</code>.</p>
|
||||
</div>
|
||||
<div class="anno-box">
|
||||
<h4>Data source — new API field</h4>
|
||||
<p>New field <code>completionPercentage: number</code> (0–100, integer) on the document search result DTO. Computed server-side:</p>
|
||||
<p><code>round((reviewedBlocks / max(totalBlocks, 1)) * 100)</code></p>
|
||||
<p>If a document has no annotation blocks yet (no transcription started), returns 0. Backend change: new subquery in the document search repository to COUNT annotation blocks (all vs. reviewed) per document, joined into the search projection.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
SECTION 6 — CONTRIBUTOR AVATARS
|
||||
══════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Section 6</div>
|
||||
<div class="sec-title">Contributor avatar stack</div>
|
||||
|
||||
<div class="two-col">
|
||||
<div class="anno-box">
|
||||
<h4>Anatomy</h4>
|
||||
<p>Reuse existing <code>ContributorStack.svelte</code> component (added in commit 031f6ea). Avatars 22×22px, <code>-ml-1.5</code> overlap, white 2px border.</p>
|
||||
<p>Show max 3 avatars. If more: <code>+N</code> text element in gray-400. When no contributors: render <code>text-[9px] text-ink-3 uppercase tracking-wide</code> label "No contributors".</p>
|
||||
</div>
|
||||
<div class="anno-box">
|
||||
<h4>Data source — new API field</h4>
|
||||
<p>New field <code>contributors: ActivityActorDTO[]</code> on the document search result DTO. <code>ActivityActorDTO</code> already exists (used in dashboard queue items): <code>{ initials: string, color: string, name?: string }</code>.</p>
|
||||
<p>Backend: join from document → annotation_blocks → created_by → users. Distinct by user. Order by most-recent contribution. Limit 4. New query in document search repository.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
SECTION 7 — BACKEND CHANGES
|
||||
══════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Section 7</div>
|
||||
<div class="sec-title">Backend changes required</div>
|
||||
|
||||
<div class="callout green">
|
||||
<div><strong class="g">New fields on document search DTO</strong> — Two new fields must be added to the object returned by <code>GET /api/documents/search</code>. These require a new projection or join in the repository layer. No schema migration needed — purely computed from existing annotation_block data.</div>
|
||||
</div>
|
||||
|
||||
<div class="impl-ref">
|
||||
<table>
|
||||
<thead><tr><th>Field</th><th>Type</th><th>Source</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>completionPercentage</code></td><td><code>int</code> (0–100)</td><td>COUNT(reviewed annotation blocks) / COUNT(all blocks)</td><td>0 when no blocks exist</td></tr>
|
||||
<tr><td><code>contributors</code></td><td><code>ActivityActorDTO[]</code></td><td>Distinct users with annotation_block contributions, ordered by recency</td><td>Max 4; reuse existing DTO</td></tr>
|
||||
<tr><td><code>archiveBox</code></td><td><code>String?</code></td><td>Already on Document entity — just not in search response</td><td><span class="changed">Expose existing field</span></td></tr>
|
||||
<tr><td><code>archiveFolder</code></td><td><code>String?</code></td><td>Already on Document entity — just not in search response</td><td><span class="changed">Expose existing field</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
SECTION 8 — IMPL-REF TABLE
|
||||
══════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Section 8 — Implementation Reference</div>
|
||||
<div class="sec-title">Exact Tailwind classes & pixel values</div>
|
||||
|
||||
<div class="impl-ref">
|
||||
<table>
|
||||
<thead><tr><th>Element</th><th>Tailwind classes</th><th>Pixels / value</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td colspan="4" style="background:rgba(0,40,80,.05);font-size:10px;font-weight:700;color:var(--navy);text-transform:uppercase;letter-spacing:.08em;">Page chrome</td></tr>
|
||||
<tr><td>Search bar wrapper</td><td><code>bg-white border-b border-line -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-3.5 flex items-center gap-3 sticky top-[65px] z-20</code></td><td>padding 14px responsive</td><td>Topbar = 1px accent + 64px nav = 65px. Negative margins break out of container padding so bar spans full container width.</td></tr>
|
||||
<tr><td>Search input</td><td><code>flex-1 h-9 border border-ink rounded-sm px-3 font-sans text-sm text-ink bg-white</code></td><td>height 36px</td><td>Active: navy border</td></tr>
|
||||
<tr><td>Sort bar wrapper</td><td><code>bg-white border-b border-line -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-2.5 flex items-center gap-3 sticky top-[113px] z-20</code></td><td>padding 10px responsive</td><td>Stacks below search bar (65 + 48 = 113px)</td></tr>
|
||||
<tr><td>Filters toggle (closed)</td><td><code>h-7 px-3 border border-line rounded-sm font-sans text-[10px] font-bold uppercase tracking-wide text-ink flex items-center gap-1.5</code></td><td>height 28px</td><td>—</td></tr>
|
||||
<tr><td>Filters toggle (open)</td><td><code>h-7 px-3 bg-ink text-white rounded-sm font-sans text-[10px] font-bold uppercase tracking-wide flex items-center gap-1.5</code></td><td>height 28px</td><td>Navy fill when active</td></tr>
|
||||
<tr><td>Filter panel wrapper</td><td><code>bg-white border-b border-line -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-4 flex flex-col sm:flex-row sm:flex-wrap gap-4</code></td><td>padding 16px responsive</td><td>Use Svelte <code>slide</code> transition; stacks vertically on mobile</td></tr>
|
||||
<tr><td>List body</td><td><code>py-5</code></td><td>vertical padding only</td><td>No extra horizontal padding — app container handles it</td></tr>
|
||||
|
||||
<tr><td colspan="4" style="background:rgba(0,40,80,.05);font-size:10px;font-weight:700;color:var(--navy);text-transform:uppercase;letter-spacing:.08em;">Year group card</td></tr>
|
||||
<tr><td>Card container</td><td><code>border border-line bg-surface shadow-sm mb-4 overflow-hidden</code></td><td>—</td><td>Matches current DocumentList outer div exactly</td></tr>
|
||||
<tr><td>Year header</td><td><code>bg-sand border-b border-line px-5 py-1.5 font-sans text-[10px] font-bold uppercase tracking-widest text-ink-3</code></td><td>padding 6px 20px</td><td>—</td></tr>
|
||||
<tr><td>Row list</td><td><code>divide-y divide-line-2</code></td><td>—</td><td>Matches current <code><ul></code> pattern</td></tr>
|
||||
|
||||
<tr><td colspan="4" style="background:rgba(0,40,80,.05);font-size:10px;font-weight:700;color:var(--navy);text-transform:uppercase;letter-spacing:.08em;">Document row</td></tr>
|
||||
<tr><td>Row wrapper <code><li></code></td><td><code>group transition-colors duration-200 hover:bg-muted/50</code></td><td>—</td><td>Same hover pattern as current</td></tr>
|
||||
<tr><td>Row inner (link)</td><td><code>block sm:flex sm:items-stretch</code></td><td>—</td><td>Full-row <code><a href="/documents/{id}"></code>; flex only on sm+</td></tr>
|
||||
<tr><td>Left column</td><td><code>p-4 sm:flex-1 sm:min-w-0 sm:pr-5 sm:border-r sm:border-line-2</code></td><td>padding 16px</td><td>Right border only on sm+</td></tr>
|
||||
<tr><td>Right column (sm+)</td><td><code>hidden sm:flex sm:w-48 lg:w-60 flex-shrink-0 p-3.5 flex-col justify-between gap-2</code></td><td>sm: 192px · lg: 240px</td><td>Hidden on mobile; narrower on tablet</td></tr>
|
||||
<tr><td>Mobile metadata grid</td><td><code>sm:hidden border-t border-line-2 mt-3 pt-3 grid grid-cols-2 gap-x-4 gap-y-0.5</code></td><td>—</td><td>2×2 compact grid shown only on mobile, inside left col</td></tr>
|
||||
<tr><td>Mobile meta bottom row</td><td><code>sm:hidden flex items-center justify-between mt-3</code></td><td>—</td><td>Ring + contributors on mobile, shown only <sm</td></tr>
|
||||
<tr><td>Document title</td><td><code>font-serif text-base font-bold text-ink mb-1.5 leading-snug group-hover:underline</code></td><td>16px / 700</td><td>—</td></tr>
|
||||
<tr><td>Snippet text</td><td><code>font-serif text-sm italic text-ink-2 line-clamp-2 mb-2</code></td><td>14px</td><td>Only when snippet present</td></tr>
|
||||
<tr><td>Meta label</td><td><code>font-sans text-[10px] font-bold uppercase tracking-wide text-ink-3 mr-1.5</code></td><td>10px / 700</td><td>DATE · FROM · TO · ARCHIVE</td></tr>
|
||||
<tr><td>Meta value</td><td><code>font-sans text-[11px] text-ink-2</code></td><td>11px</td><td>—</td></tr>
|
||||
|
||||
<tr><td colspan="4" style="background:rgba(0,40,80,.05);font-size:10px;font-weight:700;color:var(--navy);text-transform:uppercase;letter-spacing:.08em;">Progress ring</td></tr>
|
||||
<tr><td>SVG container</td><td><code>relative w-9 h-9 flex-shrink-0</code></td><td>36×36px</td><td>—</td></tr>
|
||||
<tr><td>Track circle</td><td><code>stroke="var(--c-sand)"</code> stroke-width="3"</td><td>r=13, circumference 81.7px</td><td>—</td></tr>
|
||||
<tr><td>Fill arc</td><td><code>stroke="var(--c-accent)"</code> stroke-width="3" stroke-linecap="round"</td><td>dasharray = pct/100 × 81.7</td><td>rotate(−90deg)</td></tr>
|
||||
<tr><td>Percentage label</td><td><code>absolute inset-0 flex items-center justify-center font-sans text-[8px] font-bold</code></td><td>8px / 800</td><td>Mint when >0, gray-400 when 0</td></tr>
|
||||
|
||||
<tr><td colspan="4" style="background:rgba(0,40,80,.05);font-size:10px;font-weight:700;color:var(--navy);text-transform:uppercase;letter-spacing:.08em;">New files</td></tr>
|
||||
<tr><td><span class="new">NEW</span> <code>frontend/src/routes/documents/+page.svelte</code></td><td>—</td><td>—</td><td>Document list page (extract from homepage)</td></tr>
|
||||
<tr><td><span class="new">NEW</span> <code>frontend/src/routes/documents/+page.server.ts</code></td><td>—</td><td>—</td><td>Loads search results, same API call as current homepage</td></tr>
|
||||
<tr><td><span class="changed">CHANGED</span> <code>frontend/src/routes/AppNav.svelte</code></td><td>—</td><td>—</td><td>Documents tab href: <code>/</code> → <code>/documents</code></td></tr>
|
||||
<tr><td><span class="changed">CHANGED</span> <code>frontend/src/routes/+page.svelte</code></td><td>—</td><td>—</td><td>Remove dual-mode logic; always render dashboard</td></tr>
|
||||
<tr><td><span class="changed">CHANGED</span> <code>frontend/src/routes/+page.server.ts</code></td><td>—</td><td>—</td><td>Remove search branch; always fetch dashboard data</td></tr>
|
||||
<tr><td><span class="changed">CHANGED</span> <code>frontend/src/routes/DocumentList.svelte</code></td><td>—</td><td>—</td><td>Refactor to new two-column layout + year cards</td></tr>
|
||||
<tr><td><span class="new">NEW query</span> <code>backend/.../DocumentSearchRepository</code></td><td>—</td><td>—</td><td>Add completionPercentage + contributors to search projection</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /doc -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -363,6 +363,7 @@
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
||||
"pdf_annotations_show": "Annotierungen anzeigen",
|
||||
"pdf_annotations_hide": "Annotierungen verbergen",
|
||||
"upload_action": "Hochladen",
|
||||
"upload_drop_hint": "Einzeln oder mehrere Dateien auf einmal hochladen",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
"upload_filename_hint": "Tipp: 2024-03-15_Mueller_Hans.pdf → Datum und Absender werden vorausgefüllt",
|
||||
@@ -706,5 +707,47 @@
|
||||
"admin_new_invite_expires": "Ablaufdatum (optional)",
|
||||
"admin_invite_created_title": "Einladung erstellt",
|
||||
"admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:",
|
||||
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?"
|
||||
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?",
|
||||
|
||||
"greeting_morning": "Guten Morgen, {name}.",
|
||||
"greeting_day": "Hallo, {name}.",
|
||||
"greeting_evening": "Guten Abend, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Weiter, wo du aufgehört hast",
|
||||
"dashboard_blocks": "{count} Abschnitte",
|
||||
"dashboard_resume_cta": "Weitertranskribieren",
|
||||
"dashboard_resume_other": "oder anderen Brief wählen",
|
||||
"dashboard_empty_title": "Noch kein Dokument begonnen",
|
||||
"dashboard_empty_body": "Wähle ein Dokument aus dem Archiv, um mit der Transkription zu beginnen.",
|
||||
"dashboard_empty_cta": "Zum Archiv",
|
||||
|
||||
"dashboard_mission_caption": "Offene Aufgaben",
|
||||
"queue_segment": "Segmentieren",
|
||||
"queue_segment_blurb": "Seiten aufteilen",
|
||||
"queue_transcribe": "Transkribieren",
|
||||
"queue_transcribe_blurb": "Text erfassen",
|
||||
"queue_review": "Prüfen",
|
||||
"queue_review_blurb": "Texte kontrollieren",
|
||||
"queue_n_open": "{n} offen",
|
||||
"queue_show_all": "Alle anzeigen →",
|
||||
|
||||
"pulse_eyebrow": "Diese Woche",
|
||||
"pulse_headline": "Ihr habt {pages} Seiten bearbeitet.",
|
||||
"pulse_you": "Du selbst hast {pages} davon bearbeitet.",
|
||||
"pulse_contributors": "Mitwirkende",
|
||||
"pulse_transcribed": "Textstellen markiert",
|
||||
"pulse_reviewed": "Textstellen transkribiert",
|
||||
"pulse_uploaded": "Dokumente hochgeladen",
|
||||
|
||||
"feed_caption": "Kommentare & Aktivität",
|
||||
"feed_show_all": "Alle anzeigen",
|
||||
"feed_for_you": "für dich",
|
||||
|
||||
"audit_action_text_saved": "hat Text gespeichert in",
|
||||
"audit_action_file_uploaded": "hat eine Datei hochgeladen:",
|
||||
"audit_action_annotation_created": "hat eine Markierung erstellt in",
|
||||
"audit_action_comment_added": "hat kommentiert:",
|
||||
"audit_action_mention_created": "hat dich erwähnt in",
|
||||
|
||||
"dropzone_release": "Loslassen zum Hochladen"
|
||||
}
|
||||
|
||||
@@ -363,6 +363,7 @@
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
||||
"pdf_annotations_show": "Show annotations",
|
||||
"pdf_annotations_hide": "Hide annotations",
|
||||
"upload_action": "Upload",
|
||||
"upload_drop_hint": "Drop one or multiple files at once",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
"upload_filename_hint": "Tip: 2024-03-15_Mueller_Hans.pdf → date and sender pre-filled",
|
||||
@@ -706,5 +707,47 @@
|
||||
"admin_new_invite_expires": "Expiry date (optional)",
|
||||
"admin_invite_created_title": "Invite created",
|
||||
"admin_invite_created_desc": "Share this link with the person you are inviting:",
|
||||
"admin_invite_revoke_confirm": "Really revoke this invite?"
|
||||
"admin_invite_revoke_confirm": "Really revoke this invite?",
|
||||
|
||||
"greeting_morning": "Good morning, {name}.",
|
||||
"greeting_day": "Hello, {name}.",
|
||||
"greeting_evening": "Good evening, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Continue where you left off",
|
||||
"dashboard_blocks": "{count} sections",
|
||||
"dashboard_resume_cta": "Continue transcribing",
|
||||
"dashboard_resume_other": "or choose another document",
|
||||
"dashboard_empty_title": "No document started yet",
|
||||
"dashboard_empty_body": "Choose a document from the archive to start transcribing.",
|
||||
"dashboard_empty_cta": "To the archive",
|
||||
|
||||
"dashboard_mission_caption": "Open tasks",
|
||||
"queue_segment": "Segment",
|
||||
"queue_segment_blurb": "Split pages",
|
||||
"queue_transcribe": "Transcribe",
|
||||
"queue_transcribe_blurb": "Capture text",
|
||||
"queue_review": "Review",
|
||||
"queue_review_blurb": "Check texts",
|
||||
"queue_n_open": "{n} open",
|
||||
"queue_show_all": "Show all →",
|
||||
|
||||
"pulse_eyebrow": "This week",
|
||||
"pulse_headline": "You have worked on {pages} pages.",
|
||||
"pulse_you": "You personally worked on {pages} of them.",
|
||||
"pulse_contributors": "Contributors",
|
||||
"pulse_transcribed": "Passages annotated",
|
||||
"pulse_reviewed": "Passages transcribed",
|
||||
"pulse_uploaded": "Documents uploaded",
|
||||
|
||||
"feed_caption": "Comments & activity",
|
||||
"feed_show_all": "Show all",
|
||||
"feed_for_you": "for you",
|
||||
|
||||
"audit_action_text_saved": "saved text in",
|
||||
"audit_action_file_uploaded": "uploaded a file:",
|
||||
"audit_action_annotation_created": "created an annotation in",
|
||||
"audit_action_comment_added": "commented:",
|
||||
"audit_action_mention_created": "mentioned you in",
|
||||
|
||||
"dropzone_release": "Release to upload"
|
||||
}
|
||||
|
||||
@@ -363,6 +363,7 @@
|
||||
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
||||
"pdf_annotations_show": "Mostrar anotaciones",
|
||||
"pdf_annotations_hide": "Ocultar anotaciones",
|
||||
"upload_action": "Subir",
|
||||
"upload_drop_hint": "Uno o varios archivos a la vez",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
"upload_filename_hint": "Consejo: 2024-03-15_Mueller_Hans.pdf → fecha y remitente prellenados",
|
||||
@@ -706,5 +707,47 @@
|
||||
"admin_new_invite_expires": "Fecha de vencimiento (opcional)",
|
||||
"admin_invite_created_title": "Invitación creada",
|
||||
"admin_invite_created_desc": "Comparte este enlace con la persona invitada:",
|
||||
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?"
|
||||
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?",
|
||||
|
||||
"greeting_morning": "Buenos días, {name}.",
|
||||
"greeting_day": "Hola, {name}.",
|
||||
"greeting_evening": "Buenas noches, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Continuar donde lo dejaste",
|
||||
"dashboard_blocks": "{count} secciones",
|
||||
"dashboard_resume_cta": "Continuar transcripción",
|
||||
"dashboard_resume_other": "o elige otro documento",
|
||||
"dashboard_empty_title": "Aún no has comenzado ningún documento",
|
||||
"dashboard_empty_body": "Elige un documento del archivo para empezar a transcribir.",
|
||||
"dashboard_empty_cta": "Al archivo",
|
||||
|
||||
"dashboard_mission_caption": "Tareas pendientes",
|
||||
"queue_segment": "Segmentar",
|
||||
"queue_segment_blurb": "Dividir páginas",
|
||||
"queue_transcribe": "Transcribir",
|
||||
"queue_transcribe_blurb": "Capturar texto",
|
||||
"queue_review": "Revisar",
|
||||
"queue_review_blurb": "Controlar textos",
|
||||
"queue_n_open": "{n} pendiente",
|
||||
"queue_show_all": "Ver todo →",
|
||||
|
||||
"pulse_eyebrow": "Esta semana",
|
||||
"pulse_headline": "Habéis trabajado {pages} páginas.",
|
||||
"pulse_you": "Tú mismo has trabajado {pages} de ellas.",
|
||||
"pulse_contributors": "Colaboradores",
|
||||
"pulse_transcribed": "Fragmentos anotados",
|
||||
"pulse_reviewed": "Fragmentos transcritos",
|
||||
"pulse_uploaded": "Documentos subidos",
|
||||
|
||||
"feed_caption": "Comentarios y actividad",
|
||||
"feed_show_all": "Ver todo",
|
||||
"feed_for_you": "para ti",
|
||||
|
||||
"audit_action_text_saved": "guardó texto en",
|
||||
"audit_action_file_uploaded": "subió un archivo:",
|
||||
"audit_action_annotation_created": "creó una anotación en",
|
||||
"audit_action_comment_added": "comentó:",
|
||||
"audit_action_mention_created": "te mencionó en",
|
||||
|
||||
"dropzone_release": "Suelta para subir"
|
||||
}
|
||||
|
||||
42
frontend/src/lib/components/ContributorStack.svelte
Normal file
42
frontend/src/lib/components/ContributorStack.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type ActivityActorDTO = components['schemas']['ActivityActorDTO'];
|
||||
|
||||
interface Props {
|
||||
contributors: ActivityActorDTO[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
let { contributors, hasMore }: Props = $props();
|
||||
|
||||
const safeContributors = $derived(contributors ?? []);
|
||||
</script>
|
||||
|
||||
{#if safeContributors.length === 0}
|
||||
<span
|
||||
class="inline-block h-[22px] w-[22px] flex-shrink-0 rounded-full border-[1.5px] border-dashed border-[#cdcbbf]"
|
||||
title="Noch niemand angefangen"
|
||||
></span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center">
|
||||
{#each safeContributors as actor, i (actor.initials + '-' + actor.color)}
|
||||
<span
|
||||
role="img"
|
||||
aria-label={actor.name ?? actor.initials}
|
||||
class="inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full font-sans text-[9px] font-bold text-white ring-2 ring-white {i > 0 ? '-ml-1.5' : ''}"
|
||||
style="background-color: {actor.color || '#8c9aa3'};"
|
||||
title={actor.name ?? actor.initials}
|
||||
>
|
||||
{actor.initials}
|
||||
</span>
|
||||
{/each}
|
||||
{#if hasMore}
|
||||
<span
|
||||
class="-ml-1.5 inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full bg-[#e4e2d7] font-sans text-[9px] font-bold text-ink-3 ring-2 ring-white"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
50
frontend/src/lib/components/ContributorStack.svelte.spec.ts
Normal file
50
frontend/src/lib/components/ContributorStack.svelte.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import ContributorStack from './ContributorStack.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type ActivityActorDTO = components['schemas']['ActivityActorDTO'];
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const makeActor = (overrides: Partial<ActivityActorDTO> = {}): ActivityActorDTO => ({
|
||||
initials: 'MR',
|
||||
color: '#7a4f9a',
|
||||
name: 'Max Raddatz',
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('ContributorStack', () => {
|
||||
it('contributor avatar is announced by screen readers with actor name', async () => {
|
||||
const actor = makeActor({ name: 'Anna Meier', initials: 'AM' });
|
||||
render(ContributorStack, { contributors: [actor], hasMore: false });
|
||||
await expect.element(page.getByRole('img', { name: 'Anna Meier' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to initials as accessible name when actor name is null', async () => {
|
||||
const actor = makeActor({ name: undefined, initials: 'AM' });
|
||||
render(ContributorStack, { contributors: [actor], hasMore: false });
|
||||
await expect.element(page.getByRole('img', { name: 'AM' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders two avatars without crashing when actors have identical initials', async () => {
|
||||
const actors = [
|
||||
makeActor({ name: undefined, initials: 'AM', color: '#aa0000' }),
|
||||
makeActor({ name: undefined, initials: 'AM', color: '#0000bb' })
|
||||
];
|
||||
render(ContributorStack, { contributors: actors, hasMore: false });
|
||||
await expect.element(page.getByText('AM').first()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders overflow indicator when hasMore is true', async () => {
|
||||
render(ContributorStack, { contributors: [makeActor()], hasMore: true });
|
||||
await expect.element(page.getByText('…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty placeholder when no contributors', async () => {
|
||||
render(ContributorStack, { contributors: [], hasMore: false });
|
||||
await expect.element(page.getByTitle('Noch niemand angefangen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
83
frontend/src/lib/components/DashboardActivityFeed.svelte
Normal file
83
frontend/src/lib/components/DashboardActivityFeed.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import type { ActivityFeedItemDTO } from '$lib/generated/api';
|
||||
|
||||
interface Props {
|
||||
feed: ActivityFeedItemDTO[];
|
||||
}
|
||||
|
||||
const { feed }: Props = $props();
|
||||
|
||||
const verbMap: Record<string, string> = {
|
||||
TEXT_SAVED: m.audit_action_text_saved(),
|
||||
FILE_UPLOADED: m.audit_action_file_uploaded(),
|
||||
ANNOTATION_CREATED: m.audit_action_annotation_created(),
|
||||
COMMENT_ADDED: m.audit_action_comment_added(),
|
||||
MENTION_CREATED: m.audit_action_mention_created()
|
||||
};
|
||||
|
||||
function verb(kind: string): string {
|
||||
return verbMap[kind] ?? kind;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="rounded-sm border border-line bg-surface p-5">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.feed_caption()}
|
||||
</h2>
|
||||
<a
|
||||
href="/documents"
|
||||
aria-label={m.feed_show_all()}
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink">{m.feed_show_all()}</a
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if feed.length > 0}
|
||||
<ul class="flex flex-col gap-3">
|
||||
{#each feed as item (item.happenedAt + item.documentId + item.kind)}
|
||||
<li class="flex items-start gap-3">
|
||||
{#if item.actor}
|
||||
<span
|
||||
class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full font-sans text-sm font-bold text-white"
|
||||
style="background:{item.actor.color}">{item.actor.initials}</span
|
||||
>
|
||||
{:else}
|
||||
<span
|
||||
class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-line font-sans text-sm text-ink-3"
|
||||
>?</span
|
||||
>
|
||||
{/if}
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-sans text-sm leading-snug text-ink">
|
||||
{#if item.actor}
|
||||
<strong>{item.actor.name ?? item.actor.initials}</strong>
|
||||
{/if}
|
||||
{verb(item.kind)}
|
||||
<a href="/documents/{item.documentId}" class="underline hover:text-ink">
|
||||
{item.documentTitle}
|
||||
</a>
|
||||
{#if item.youMentioned}
|
||||
<span
|
||||
class="ml-1.5 inline-block rounded-full border border-accent px-2 py-px font-sans text-[10px] font-bold text-accent"
|
||||
>
|
||||
{m.feed_for_you()}
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="mt-0.5 font-sans text-xs text-ink-3">{formatDate(item.happenedAt)}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import DashboardActivityFeed from './DashboardActivityFeed.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const baseItem: ActivityFeedItemDTO = {
|
||||
kind: 'TEXT_SAVED',
|
||||
actor: { initials: 'MR', color: '#7a4f9a', name: 'Max Raddatz' },
|
||||
documentId: 'doc-1',
|
||||
documentTitle: 'Brief 1920',
|
||||
happenedAt: '2026-04-19T10:00:00Z',
|
||||
youMentioned: false
|
||||
};
|
||||
|
||||
describe('DashboardActivityFeed', () => {
|
||||
it('renders "für dich" badge when youMentioned is true', async () => {
|
||||
const item: ActivityFeedItemDTO = { ...baseItem, kind: 'MENTION_CREATED', youMentioned: true };
|
||||
render(DashboardActivityFeed, { feed: [item] });
|
||||
const badge = page.getByText('für dich');
|
||||
await expect.element(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render "für dich" badge when youMentioned is false', async () => {
|
||||
render(DashboardActivityFeed, { feed: [baseItem] });
|
||||
const badge = page.getByText('für dich');
|
||||
await expect.element(badge).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when feed is empty', async () => {
|
||||
render(DashboardActivityFeed, { feed: [] });
|
||||
const section = page.getByText('Kommentare & Aktivität');
|
||||
await expect.element(section).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
70
frontend/src/lib/components/DashboardFamilyPulse.svelte
Normal file
70
frontend/src/lib/components/DashboardFamilyPulse.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import type { DashboardPulseDTO } from '$lib/generated/api';
|
||||
|
||||
interface Props {
|
||||
pulse: DashboardPulseDTO | null;
|
||||
}
|
||||
|
||||
const { pulse }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if pulse !== null}
|
||||
<section class="rounded-sm border border-line bg-surface p-5">
|
||||
<p class="font-sans text-[11px] font-bold tracking-[.12em] text-ink-3 uppercase">
|
||||
{m.pulse_eyebrow()}
|
||||
</p>
|
||||
|
||||
{#if pulse.pages > 0}
|
||||
<h2 class="mt-1 font-serif text-[1.375rem] leading-snug text-ink">
|
||||
{m.pulse_headline({ pages: pulse.pages })}
|
||||
</h2>
|
||||
{/if}
|
||||
|
||||
{#if pulse.yourPages > 0}
|
||||
<p class="font-serif text-sm text-ink-2">
|
||||
{m.pulse_you({ pages: pulse.yourPages })}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if pulse.contributors.length > 0}
|
||||
<div class="mt-3 flex items-center gap-1">
|
||||
<p class="mr-1 font-sans text-[11px] text-ink-3">{m.pulse_contributors()}</p>
|
||||
{#each pulse.contributors as c (c.initials + c.color)}
|
||||
<span
|
||||
class="-ml-2 inline-flex h-7 w-7 items-center justify-center rounded-full font-sans text-[11px] font-bold text-white ring-2 ring-white first:ml-0"
|
||||
style="background:{c.color}"
|
||||
title={c.name ?? ''}>{c.initials}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 grid grid-cols-3 gap-2">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-serif text-[1.875rem] leading-none font-bold text-ink"
|
||||
>{pulse.annotated}</span
|
||||
>
|
||||
<span class="flex items-center gap-1 font-sans text-[11px] text-ink-3">
|
||||
<span class="text-[8px]" style="color:#00c7b1">●</span>{m.pulse_transcribed()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-serif text-[1.875rem] leading-none font-bold text-ink"
|
||||
>{pulse.transcribed}</span
|
||||
>
|
||||
<span class="flex items-center gap-1 font-sans text-[11px] text-ink-3">
|
||||
<span class="text-[8px]" style="color:#5a8a6a">●</span>{m.pulse_reviewed()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-serif text-[1.875rem] leading-none font-bold text-ink"
|
||||
>{pulse.uploaded}</span
|
||||
>
|
||||
<span class="flex items-center gap-1 font-sans text-[11px] text-ink-3">
|
||||
<span class="text-[8px]" style="color:#3060b0">●</span>{m.pulse_uploaded()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -1,37 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import type { DashboardResumeDTO } from '$lib/generated/api';
|
||||
|
||||
interface LastVisited {
|
||||
id: string;
|
||||
title: string;
|
||||
interface Props {
|
||||
resumeDoc: DashboardResumeDTO | null;
|
||||
}
|
||||
|
||||
let lastVisited = $state<LastVisited | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('familienarchiv.lastVisited');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as LastVisited;
|
||||
if (parsed?.id) {
|
||||
lastVisited = parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed JSON
|
||||
}
|
||||
});
|
||||
const { resumeDoc }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if lastVisited}
|
||||
{#if resumeDoc === null}
|
||||
<div
|
||||
data-testid="resume-strip"
|
||||
class="flex items-center gap-2 rounded-sm border border-line bg-surface px-4 py-3 font-sans text-sm"
|
||||
data-testid="resume-strip-empty"
|
||||
class="rounded-sm border border-line bg-surface p-8 text-center"
|
||||
>
|
||||
<span class="text-ink-2">{m.dashboard_resume_label()}</span>
|
||||
<a href="/documents/{lastVisited.id}" class="font-medium text-ink hover:underline">
|
||||
{lastVisited.title || m.dashboard_resume_fallback()}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="mx-auto text-ink-3"
|
||||
>
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</svg>
|
||||
<h2 class="mt-3 font-serif text-xl text-ink">{m.dashboard_empty_title()}</h2>
|
||||
<p class="mt-1 font-sans text-sm text-ink-2">{m.dashboard_empty_body()}</p>
|
||||
<a
|
||||
href="/documents"
|
||||
class="mt-4 inline-block font-sans text-sm font-bold text-accent hover:underline"
|
||||
>
|
||||
{m.dashboard_empty_cta()}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div data-testid="resume-strip" class="flex gap-4 rounded-sm border border-line bg-surface p-5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="180"
|
||||
height="246"
|
||||
viewBox="0 0 180 246"
|
||||
aria-hidden="true"
|
||||
class="shrink-0"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="parchment" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#f5f0e8" />
|
||||
<stop offset="100%" stop-color="#ede8d5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="180" height="246" fill="url(#parchment)" />
|
||||
<line x1="30" y1="40" x2="150" y2="40" stroke="#b0a898" stroke-width="1" />
|
||||
<line x1="30" y1="70" x2="150" y2="70" stroke="#b0a898" stroke-width="1" />
|
||||
<line x1="30" y1="100" x2="150" y2="100" stroke="#b0a898" stroke-width="1" />
|
||||
<line x1="30" y1="130" x2="150" y2="130" stroke="#b0a898" stroke-width="1" />
|
||||
<line x1="30" y1="160" x2="150" y2="160" stroke="#b0a898" stroke-width="1" />
|
||||
</svg>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<p class="flex items-center gap-1.5 font-sans text-xs text-ink-3">
|
||||
<span class="text-[#A6DAD8]">●</span>
|
||||
{m.dashboard_resume_label()}
|
||||
·
|
||||
{m.dashboard_blocks({ count: resumeDoc.totalBlocks })}
|
||||
</p>
|
||||
|
||||
<h2 class="font-serif text-[1.75rem] leading-tight text-ink">{resumeDoc.title}</h2>
|
||||
|
||||
<p class="font-sans text-sm text-ink-2 italic">{resumeDoc.caption}</p>
|
||||
|
||||
<blockquote
|
||||
class="border-l-[3px] border-accent pl-3 font-serif text-[1.0625rem] leading-relaxed text-ink-2"
|
||||
>
|
||||
{resumeDoc.excerpt}
|
||||
</blockquote>
|
||||
|
||||
<div class="mt-auto flex items-center gap-3 pt-2">
|
||||
<span class="font-sans text-xs font-bold text-ink">{resumeDoc.pct}%</span>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={resumeDoc.pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
class="h-1.5 flex-1 overflow-hidden rounded-full bg-line"
|
||||
>
|
||||
<div class="h-full rounded-full bg-accent" style="width:{resumeDoc.pct}%"></div>
|
||||
</div>
|
||||
{#each resumeDoc.collaborators.slice(0, 3) as collab (collab.initials + collab.color)}
|
||||
<span
|
||||
class="-ml-1 inline-flex h-6 w-6 items-center justify-center rounded-full font-sans text-[10px] font-bold text-white ring-2 ring-white"
|
||||
style="background:{collab.color}">{collab.initials}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-1 flex items-center gap-4">
|
||||
<a
|
||||
href="/documents/{resumeDoc.documentId}"
|
||||
class="inline-block rounded-sm bg-accent px-4 py-1.5 font-sans text-sm font-bold text-white transition-opacity hover:opacity-90"
|
||||
>
|
||||
{m.dashboard_resume_cta()}
|
||||
</a>
|
||||
<a href="/documents" class="font-sans text-xs text-ink-3 transition-colors hover:text-ink">
|
||||
{m.dashboard_resume_other()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -3,48 +3,53 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import DashboardResumeStrip from './DashboardResumeStrip.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type DashboardResumeDTO = components['schemas']['DashboardResumeDTO'];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const mockResume: DashboardResumeDTO = {
|
||||
documentId: 'doc-123',
|
||||
title: 'Geburtsurkunde 1920',
|
||||
caption: 'Max Mustermann · 1920-01-01',
|
||||
excerpt: 'Hiermit wird beurkundet…',
|
||||
totalBlocks: 4,
|
||||
pct: 75,
|
||||
collaborators: []
|
||||
};
|
||||
|
||||
describe('DashboardResumeStrip', () => {
|
||||
it('renders nothing when no last-visited document in localStorage', async () => {
|
||||
render(DashboardResumeStrip, {});
|
||||
const strip = page.getByTestId('resume-strip');
|
||||
await expect.element(strip).not.toBeInTheDocument();
|
||||
it('renders empty state heading when resumeDoc is null', async () => {
|
||||
render(DashboardResumeStrip, { resumeDoc: null });
|
||||
const heading = page.getByRole('heading', { name: /Noch kein Dokument begonnen/i });
|
||||
await expect.element(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the strip with link when localStorage has a document', async () => {
|
||||
localStorage.setItem(
|
||||
'familienarchiv.lastVisited',
|
||||
JSON.stringify({ id: 'doc-123', title: 'Geburtsurkunde 1920' })
|
||||
);
|
||||
render(DashboardResumeStrip, {});
|
||||
const strip = page.getByTestId('resume-strip');
|
||||
await expect.element(strip).toBeInTheDocument();
|
||||
const link = page.getByRole('link', { name: /Geburtsurkunde 1920/ });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
it('renders progressbar with correct aria-valuenow when resumeDoc is provided', async () => {
|
||||
render(DashboardResumeStrip, { resumeDoc: mockResume });
|
||||
const bar = page.getByRole('progressbar');
|
||||
await expect.element(bar).toBeInTheDocument();
|
||||
await expect.element(bar).toHaveAttribute('aria-valuenow', '75');
|
||||
});
|
||||
|
||||
it('shows document title when resumeDoc is provided', async () => {
|
||||
render(DashboardResumeStrip, { resumeDoc: mockResume });
|
||||
const title = page.getByRole('heading', { name: /Geburtsurkunde 1920/i });
|
||||
await expect.element(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links to the document for the CTA', async () => {
|
||||
render(DashboardResumeStrip, { resumeDoc: mockResume });
|
||||
const link = page.getByRole('link', { name: /Weitertranskribieren/i });
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/doc-123');
|
||||
});
|
||||
|
||||
it('uses title fallback text when title is empty', async () => {
|
||||
localStorage.setItem(
|
||||
'familienarchiv.lastVisited',
|
||||
JSON.stringify({ id: 'doc-456', title: '' })
|
||||
);
|
||||
render(DashboardResumeStrip, {});
|
||||
const strip = page.getByTestId('resume-strip');
|
||||
await expect.element(strip).toBeInTheDocument();
|
||||
const link = page.getByRole('link');
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/doc-456');
|
||||
});
|
||||
|
||||
it('renders nothing when localStorage contains malformed JSON', async () => {
|
||||
localStorage.setItem('familienarchiv.lastVisited', '{not valid json');
|
||||
render(DashboardResumeStrip, {});
|
||||
const strip = page.getByTestId('resume-strip');
|
||||
await expect.element(strip).not.toBeInTheDocument();
|
||||
it('shows block count label', async () => {
|
||||
render(DashboardResumeStrip, { resumeDoc: mockResume });
|
||||
const label = page.getByText(/4 Abschnitte/i);
|
||||
await expect.element(label).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,8 @@ function makeDoc(
|
||||
annotationCount: 0,
|
||||
textedBlockCount: 0,
|
||||
reviewedBlockCount: 0,
|
||||
contributors: [],
|
||||
hasMoreContributors: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as m from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import { formatMCDate } from '$lib/utils/date.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import ContributorStack from './ContributorStack.svelte';
|
||||
|
||||
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
|
||||
|
||||
@@ -51,6 +52,9 @@ function reviewedPct(doc: TranscriptionQueueItemDTO): number {
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<ContributorStack contributors={doc.contributors} hasMore={doc.hasMoreContributors} />
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -15,6 +15,8 @@ function makeDoc(overrides: Partial<TranscriptionQueueItemDTO> = {}): Transcript
|
||||
annotationCount: 0,
|
||||
textedBlockCount: 0,
|
||||
reviewedBlockCount: 0,
|
||||
contributors: [],
|
||||
hasMoreContributors: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as m from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import { formatMCDate } from '$lib/utils/date.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import ContributorStack from './ContributorStack.svelte';
|
||||
|
||||
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
|
||||
|
||||
@@ -44,6 +45,9 @@ let { docs, weeklyCount }: Props = $props();
|
||||
>{formatMCDate(doc.documentDate, getLocale())}</span
|
||||
>
|
||||
{/if}
|
||||
<div class="mt-1">
|
||||
<ContributorStack contributors={doc.contributors} hasMore={doc.hasMoreContributors} />
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -15,6 +15,8 @@ function makeDoc(overrides: Partial<TranscriptionQueueItemDTO> = {}): Transcript
|
||||
annotationCount: 0,
|
||||
textedBlockCount: 0,
|
||||
reviewedBlockCount: 0,
|
||||
contributors: [],
|
||||
hasMoreContributors: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as m from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import { formatMCDate } from '$lib/utils/date.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import ContributorStack from './ContributorStack.svelte';
|
||||
|
||||
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
|
||||
|
||||
@@ -67,6 +68,9 @@ function blockProgress(doc: TranscriptionQueueItemDTO): number {
|
||||
{:else}
|
||||
<span class="mt-0.5 text-xs text-ink-3 italic">—</span>
|
||||
{/if}
|
||||
<div class="mt-1">
|
||||
<ContributorStack contributors={doc.contributors} hasMore={doc.hasMoreContributors} />
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -15,6 +15,8 @@ function makeDoc(overrides: Partial<TranscriptionQueueItemDTO> = {}): Transcript
|
||||
annotationCount: 0,
|
||||
textedBlockCount: 0,
|
||||
reviewedBlockCount: 0,
|
||||
contributors: [],
|
||||
hasMoreContributors: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
@@ -324,6 +324,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/invites": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listInvites"];
|
||||
put?: never;
|
||||
post: operations["createInvite"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/groups": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -356,6 +372,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/file": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getDocumentFile"];
|
||||
put?: never;
|
||||
post: operations["attachFile"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/transcription-blocks": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -532,6 +564,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/register": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["register"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/forgot-password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1044,22 +1092,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/file": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getDocumentFile"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/transcription-blocks/{blockId}/history": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1108,38 +1140,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/recent-activity": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getRecentActivity"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/incomplete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getIncomplete"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/incomplete/next": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1188,6 +1188,70 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/dashboard/resume": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getResume"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/dashboard/pulse": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getPulse"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/dashboard/activity": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getActivity"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/invite/{code}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getInvitePrefill"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/import-status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1236,6 +1300,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/invites/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["revokeInvite"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -1253,12 +1333,13 @@ export interface components {
|
||||
AppUser: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: email */
|
||||
email: string;
|
||||
password?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: date */
|
||||
birthDate?: string;
|
||||
email: string;
|
||||
contact?: string;
|
||||
enabled: boolean;
|
||||
notifyOnReply: boolean;
|
||||
@@ -1266,6 +1347,7 @@ export interface components {
|
||||
groups: components["schemas"]["UserGroup"][];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
color: string;
|
||||
};
|
||||
UserGroup: {
|
||||
/** Format: uuid */
|
||||
@@ -1405,6 +1487,7 @@ export interface components {
|
||||
blockIds?: string[];
|
||||
};
|
||||
CreateUserRequest: {
|
||||
/** Format: email */
|
||||
email: string;
|
||||
initialPassword?: string;
|
||||
groupIds?: string[];
|
||||
@@ -1470,11 +1553,40 @@ export interface components {
|
||||
};
|
||||
TriggerSenderTrainingDTO: {
|
||||
/** Format: uuid */
|
||||
personId?: string;
|
||||
personId: string;
|
||||
};
|
||||
BatchOcrDTO: {
|
||||
documentIds: string[];
|
||||
};
|
||||
CreateInviteRequest: {
|
||||
label?: string;
|
||||
/** Format: int32 */
|
||||
maxUses?: number;
|
||||
prefillFirstName?: string;
|
||||
prefillLastName?: string;
|
||||
prefillEmail?: string;
|
||||
groupIds?: string[];
|
||||
/** Format: date-time */
|
||||
expiresAt?: string;
|
||||
};
|
||||
InviteListItemDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
code: string;
|
||||
displayCode: string;
|
||||
label?: string;
|
||||
/** Format: int32 */
|
||||
useCount: number;
|
||||
/** Format: int32 */
|
||||
maxUses?: number;
|
||||
/** Format: date-time */
|
||||
expiresAt?: string;
|
||||
revoked: boolean;
|
||||
status: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
shareableUrl?: string;
|
||||
};
|
||||
GroupDTO: {
|
||||
name?: string;
|
||||
permissions?: string[];
|
||||
@@ -1580,6 +1692,15 @@ export interface components {
|
||||
token?: string;
|
||||
newPassword?: string;
|
||||
};
|
||||
RegisterRequest: {
|
||||
code: string;
|
||||
/** Format: email */
|
||||
email: string;
|
||||
password: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
notifyOnMention?: boolean;
|
||||
};
|
||||
ForgotPasswordRequest: {
|
||||
email?: string;
|
||||
};
|
||||
@@ -1633,6 +1754,11 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
transcriptionCount: number;
|
||||
};
|
||||
ActivityActorDTO: {
|
||||
initials: string;
|
||||
color: string;
|
||||
name?: string;
|
||||
};
|
||||
TranscriptionQueueItemDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -1645,6 +1771,8 @@ export interface components {
|
||||
textedBlockCount: number;
|
||||
/** Format: int32 */
|
||||
reviewedBlockCount: number;
|
||||
contributors: components["schemas"]["ActivityActorDTO"][];
|
||||
hasMoreContributors: boolean;
|
||||
};
|
||||
TagTreeNodeDTO: {
|
||||
/** Format: uuid */
|
||||
@@ -1754,6 +1882,8 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
totalElements?: number;
|
||||
pageable?: components["schemas"]["PageableObject"];
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
content?: components["schemas"]["NotificationDTO"][];
|
||||
@@ -1762,8 +1892,6 @@ export interface components {
|
||||
sort?: components["schemas"]["SortObject"];
|
||||
/** Format: int32 */
|
||||
numberOfElements?: number;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
empty?: boolean;
|
||||
};
|
||||
PageableObject: {
|
||||
@@ -1847,10 +1975,47 @@ export interface components {
|
||||
summarySnippet?: string;
|
||||
summaryOffsets: components["schemas"]["MatchOffset"][];
|
||||
};
|
||||
IncompleteDocumentDTO: {
|
||||
DashboardResumeDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
documentId: string;
|
||||
title: string;
|
||||
caption: string;
|
||||
excerpt: string;
|
||||
/** Format: int32 */
|
||||
totalBlocks: number;
|
||||
/** Format: int32 */
|
||||
pct: number;
|
||||
thumbnailUrl?: string;
|
||||
collaborators: components["schemas"]["ActivityActorDTO"][];
|
||||
};
|
||||
DashboardPulseDTO: {
|
||||
/** Format: int32 */
|
||||
pages: number;
|
||||
/** Format: int32 */
|
||||
annotated: number;
|
||||
/** Format: int32 */
|
||||
transcribed: number;
|
||||
/** Format: int32 */
|
||||
uploaded: number;
|
||||
/** Format: int32 */
|
||||
yourPages: number;
|
||||
contributors: components["schemas"]["ActivityActorDTO"][];
|
||||
};
|
||||
ActivityFeedItemDTO: {
|
||||
/** @enum {string} */
|
||||
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED";
|
||||
actor?: components["schemas"]["ActivityActorDTO"];
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
documentTitle: string;
|
||||
/** Format: date-time */
|
||||
happenedAt: string;
|
||||
youMentioned: boolean;
|
||||
};
|
||||
InvitePrefillDTO: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
@@ -2619,6 +2784,52 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
listInvites: {
|
||||
parameters: {
|
||||
query?: {
|
||||
status?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["InviteListItemDTO"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
createInvite: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateInviteRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["InviteListItemDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getAllGroups: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2687,6 +2898,57 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getDocumentFile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
attachFile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
/** Format: binary */
|
||||
file: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listBlocks: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3086,6 +3348,30 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
register: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["RegisterRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["AppUser"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
forgotPassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3603,9 +3889,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
"*/*": components["schemas"]["TrainingInfoResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -3850,28 +4134,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getDocumentFile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getBlockHistory: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3953,51 +4215,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getRecentActivity: {
|
||||
parameters: {
|
||||
query?: {
|
||||
size?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getIncomplete: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Maximum number of results */
|
||||
size?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["IncompleteDocumentDTO"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getNextIncomplete: {
|
||||
parameters: {
|
||||
query: {
|
||||
@@ -4068,6 +4285,90 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getResume: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DashboardResumeDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getPulse: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DashboardPulseDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getActivity: {
|
||||
parameters: {
|
||||
query?: {
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ActivityFeedItemDTO"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getInvitePrefill: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
code: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["InvitePrefillDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
importStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4129,4 +4430,28 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
revokeInvite: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type DashboardResumeDTO = components['schemas']['DashboardResumeDTO'];
|
||||
export type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
|
||||
export type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import './layout.css';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import NotificationBell from '$lib/components/NotificationBell.svelte';
|
||||
@@ -28,7 +29,9 @@ onMount(() => {
|
||||
});
|
||||
|
||||
const isAuthPage = $derived(
|
||||
['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p))
|
||||
['/login', '/register', '/forgot-password', '/reset-password'].some((p) =>
|
||||
page.url.pathname.startsWith(p)
|
||||
)
|
||||
);
|
||||
|
||||
const userInitials = $derived.by(() => {
|
||||
@@ -50,6 +53,31 @@ const userInitials = $derived.by(() => {
|
||||
|
||||
<!-- Right Side -->
|
||||
<div class="flex items-center gap-3">
|
||||
{#if data?.user}
|
||||
<a
|
||||
href="/documents/new"
|
||||
aria-label={m.upload_action()}
|
||||
class="inline-flex items-center gap-2 rounded-sm border border-white/25 px-3.5 py-1.5 font-sans text-[11px] font-bold tracking-[.12em] text-white uppercase transition-colors hover:bg-white/10"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
{m.upload_action()}
|
||||
</a>
|
||||
{/if}
|
||||
<!-- Language selector (desktop only — mobile lives in nav drawer) -->
|
||||
<div
|
||||
class="hidden items-center gap-1 pr-3 lg:flex [&_button]:px-1.5 [&_button]:py-1 [&_button]:text-xs"
|
||||
|
||||
@@ -2,12 +2,14 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
|
||||
type StatsDTO = components['schemas']['StatsDTO'];
|
||||
type Document = components['schemas']['Document'];
|
||||
type SearchMatchData = components['schemas']['SearchMatchData'];
|
||||
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
|
||||
type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO'];
|
||||
type DashboardResumeDTO = components['schemas']['DashboardResumeDTO'];
|
||||
type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
const q = url.searchParams.get('q') || '';
|
||||
@@ -81,10 +83,10 @@ export async function load({ url, fetch }) {
|
||||
const senderObj = allPersons.find((p) => p.id === senderId);
|
||||
const receiverObj = allPersons.find((p) => p.id === receiverId);
|
||||
|
||||
// Dashboard widgets — failures are isolated and don't crash the page
|
||||
let stats: StatsDTO | null = null;
|
||||
let incompleteDocs: IncompleteDocumentDTO[] = [];
|
||||
let recentDocs: Document[] = [];
|
||||
let resumeDoc: DashboardResumeDTO | null = null;
|
||||
let pulse: DashboardPulseDTO | null = null;
|
||||
let activityFeed: ActivityFeedItemDTO[] = [];
|
||||
let segmentationDocs: TranscriptionQueueItemDTO[] = [];
|
||||
let transcriptionDocs: TranscriptionQueueItemDTO[] = [];
|
||||
let readyDocs: TranscriptionQueueItemDTO[] = [];
|
||||
@@ -93,16 +95,18 @@ export async function load({ url, fetch }) {
|
||||
if (isDashboard) {
|
||||
const [
|
||||
statsResult,
|
||||
incompleteResult,
|
||||
recentResult,
|
||||
resumeResult,
|
||||
pulseResult,
|
||||
activityResult,
|
||||
segmentationResult,
|
||||
transcriptionResult,
|
||||
readyResult,
|
||||
weeklyStatsResult
|
||||
] = await Promise.allSettled([
|
||||
api.GET('/api/stats'),
|
||||
api.GET('/api/documents/incomplete', { params: { query: { size: 3 } } }),
|
||||
api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } }),
|
||||
api.GET('/api/dashboard/resume'),
|
||||
api.GET('/api/dashboard/pulse'),
|
||||
api.GET('/api/dashboard/activity', { params: { query: { limit: 7 } } }),
|
||||
api.GET('/api/transcription/segmentation-queue'),
|
||||
api.GET('/api/transcription/transcription-queue'),
|
||||
api.GET('/api/transcription/ready-to-read'),
|
||||
@@ -112,11 +116,14 @@ export async function load({ url, fetch }) {
|
||||
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
|
||||
stats = statsResult.value.data ?? null;
|
||||
}
|
||||
if (incompleteResult.status === 'fulfilled' && incompleteResult.value.response.ok) {
|
||||
incompleteDocs = incompleteResult.value.data ?? [];
|
||||
if (resumeResult.status === 'fulfilled' && resumeResult.value.response.ok) {
|
||||
resumeDoc = (resumeResult.value.data as DashboardResumeDTO) ?? null;
|
||||
}
|
||||
if (recentResult.status === 'fulfilled' && recentResult.value.response.ok) {
|
||||
recentDocs = recentResult.value.data ?? [];
|
||||
if (pulseResult.status === 'fulfilled' && pulseResult.value.response.ok) {
|
||||
pulse = (pulseResult.value.data as DashboardPulseDTO) ?? null;
|
||||
}
|
||||
if (activityResult.status === 'fulfilled' && activityResult.value.response.ok) {
|
||||
activityFeed = (activityResult.value.data as ActivityFeedItemDTO[]) ?? [];
|
||||
}
|
||||
if (segmentationResult.status === 'fulfilled' && segmentationResult.value.response.ok) {
|
||||
segmentationDocs = (segmentationResult.value.data ?? []) as TranscriptionQueueItemDTO[];
|
||||
@@ -138,15 +145,16 @@ export async function load({ url, fetch }) {
|
||||
total,
|
||||
matchData,
|
||||
stats,
|
||||
incompleteDocs,
|
||||
recentDocs,
|
||||
resumeDoc,
|
||||
pulse,
|
||||
activityFeed,
|
||||
segmentationDocs,
|
||||
transcriptionDocs,
|
||||
readyDocs,
|
||||
weeklyStats,
|
||||
initialValues: {
|
||||
senderName: senderObj?.displayName ?? '',
|
||||
receiverName: receiverObj?.displayName ?? ''
|
||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}`.trim() : '',
|
||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}`.trim() : ''
|
||||
},
|
||||
filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ, tagOp },
|
||||
error: null as string | null
|
||||
@@ -160,8 +168,9 @@ export async function load({ url, fetch }) {
|
||||
total: 0,
|
||||
matchData: {} as Record<string, SearchMatchData>,
|
||||
stats: null,
|
||||
incompleteDocs: [],
|
||||
recentDocs: [],
|
||||
resumeDoc: null,
|
||||
pulse: null,
|
||||
activityFeed: [],
|
||||
segmentationDocs: [],
|
||||
transcriptionDocs: [],
|
||||
readyDocs: [],
|
||||
|
||||
@@ -7,10 +7,10 @@ import SearchFilterBar from './SearchFilterBar.svelte';
|
||||
import DropZone from './DropZone.svelte';
|
||||
import DocumentList from './DocumentList.svelte';
|
||||
import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte';
|
||||
import DashboardNeedsMetadata from '$lib/components/DashboardNeedsMetadata.svelte';
|
||||
import DashboardRecentDocuments from '$lib/components/DashboardRecentDocuments.svelte';
|
||||
import MissionControlStrip from '$lib/components/MissionControlStrip.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import DashboardFamilyPulse from '$lib/components/DashboardFamilyPulse.svelte';
|
||||
import DashboardActivityFeed from '$lib/components/DashboardActivityFeed.svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -66,7 +66,6 @@ function handleImmediateSearch() {
|
||||
triggerSearch();
|
||||
}
|
||||
|
||||
// Trigger search when tags change
|
||||
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
|
||||
$effect(() => {
|
||||
const cur = tagNames.map((t) => t.name).join(',');
|
||||
@@ -76,8 +75,6 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Sync local state with server data after navigation.
|
||||
// Guard q: skip overwrite while the user is actively typing in the search field.
|
||||
$effect(() => {
|
||||
if (!qFocused) q = data.filters?.q || '';
|
||||
from = data.filters?.from || '';
|
||||
@@ -92,10 +89,13 @@ $effect(() => {
|
||||
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
|
||||
});
|
||||
|
||||
// Right column is only rendered when there is something to show.
|
||||
// Omitting it prevents an empty 300px ghost column for read-only users
|
||||
// with a complete archive.
|
||||
const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?? 0) > 0);
|
||||
const greetingText = $derived.by(() => {
|
||||
const name = data?.user?.firstName ?? '';
|
||||
const h = new Date().getHours();
|
||||
if (h < 12) return m.greeting_morning({ name });
|
||||
if (h < 18) return m.greeting_day({ name });
|
||||
return m.greeting_evening({ name });
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -125,35 +125,37 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
|
||||
/>
|
||||
|
||||
{#if data.isDashboard}
|
||||
<DashboardResumeStrip />
|
||||
{#if data?.user}
|
||||
<div class="mb-6">
|
||||
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Classic Split: right column first in DOM so it appears above recent docs on mobile.
|
||||
lg:order-last moves it back to the visual right on desktop. -->
|
||||
<!-- No items-start — CSS Grid stretch default makes both columns equal height -->
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 {showRightColumn ? 'lg:grid-cols-[1fr_300px]' : ''}">
|
||||
{#if showRightColumn}
|
||||
<div data-testid="dashboard-right-column" class="flex h-full flex-col gap-4 lg:order-last">
|
||||
{#if data.canWrite}
|
||||
<DropZone />
|
||||
{/if}
|
||||
<!-- flex-1 + min-h-0 fills remaining height after DropZone.
|
||||
min-h-0 overrides the default min-height:auto that prevents flex
|
||||
children from shrinking below their content size. -->
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<DashboardNeedsMetadata incompleteDocs={data.incompleteDocs ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
||||
<div class="flex flex-col gap-5">
|
||||
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
|
||||
|
||||
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
|
||||
<section aria-label={m.dashboard_mission_caption()}>
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_mission_caption()}
|
||||
</h2>
|
||||
<MissionControlStrip
|
||||
segmentationDocs={data.segmentationDocs ?? []}
|
||||
transcriptionDocs={data.transcriptionDocs ?? []}
|
||||
readyDocs={data.readyDocs ?? []}
|
||||
weeklyStats={data.weeklyStats ?? null}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
|
||||
<DashboardFamilyPulse pulse={data.pulse ?? null} />
|
||||
<DashboardActivityFeed feed={data.activityFeed ?? []} />
|
||||
{#if data.canWrite}
|
||||
<DropZone />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MissionControlStrip
|
||||
segmentationDocs={data.segmentationDocs ?? []}
|
||||
transcriptionDocs={data.transcriptionDocs ?? []}
|
||||
readyDocs={data.readyDocs ?? []}
|
||||
weeklyStats={data.weeklyStats ?? null}
|
||||
/>
|
||||
{:else}
|
||||
<DocumentList
|
||||
documents={data.documents ?? []}
|
||||
|
||||
@@ -48,6 +48,22 @@ describe('Layout – user avatar button', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Upload link ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Layout – upload link', () => {
|
||||
it('has aria-label for screen reader access', async () => {
|
||||
render(Layout, { data: makeData(), children: emptySnippet });
|
||||
const link = page.getByRole('link', { name: /Hochladen|Upload|Subir/i });
|
||||
await expect.element(link).toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
it('navigates to /documents/new', async () => {
|
||||
render(Layout, { data: makeData(), children: emptySnippet });
|
||||
const link = page.getByRole('link', { name: /Hochladen|Upload|Subir/i });
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/new');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dropdown ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Layout – user dropdown', () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ function makeUrl(params: Record<string, string | string[]> = {}) {
|
||||
// ─── dashboard mode (no search filters) ──────────────────────────────────────
|
||||
|
||||
describe('home page load — dashboard mode', () => {
|
||||
it('sets isDashboard true and fetches stats, incomplete, and recent APIs', async () => {
|
||||
it('sets isDashboard true and fetches stats, resume, pulse, and activity APIs', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
@@ -30,8 +30,30 @@ describe('home page load — dashboard mode', () => {
|
||||
response: { ok: true },
|
||||
data: { totalDocuments: 42, totalPersons: 7 }
|
||||
}) // stats
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1' }] }) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd2' }] }) // recent
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: {
|
||||
documentId: 'd1',
|
||||
title: 'T',
|
||||
caption: '',
|
||||
excerpt: '',
|
||||
totalBlocks: 2,
|
||||
pct: 50,
|
||||
collaborators: []
|
||||
}
|
||||
}) // resume
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: {
|
||||
pages: 5,
|
||||
annotated: 1,
|
||||
transcribed: 2,
|
||||
uploaded: 1,
|
||||
yourPages: 3,
|
||||
contributors: []
|
||||
}
|
||||
}) // pulse
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // activity
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // segmentation-queue
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // transcription-queue
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // ready-to-read
|
||||
@@ -47,8 +69,10 @@ describe('home page load — dashboard mode', () => {
|
||||
|
||||
expect(result.isDashboard).toBe(true);
|
||||
expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 });
|
||||
expect(result.incompleteDocs).toHaveLength(1);
|
||||
expect(result.recentDocs).toHaveLength(1);
|
||||
expect(result.resumeDoc).not.toBeNull();
|
||||
expect(result.resumeDoc?.totalBlocks).toBe(2);
|
||||
expect(result.pulse).not.toBeNull();
|
||||
expect(result.activityFeed).toEqual([]);
|
||||
expect(result.documents).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -60,8 +84,9 @@ describe('home page load — dashboard mode', () => {
|
||||
response: { ok: true },
|
||||
data: { totalDocuments: 248, totalPersons: 34 }
|
||||
}) // stats
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // recent
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: null }) // resume
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: null }) // pulse
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // activity
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // segmentation-queue
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // transcription-queue
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // ready-to-read
|
||||
@@ -95,23 +120,24 @@ describe('home page load — dashboard mode', () => {
|
||||
expect(result.stats).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults incompleteDocs to [] when incomplete API rejects', async () => {
|
||||
it('defaults resumeDoc to null when resume API rejects', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // notifications
|
||||
.mockRejectedValueOnce(new Error('network')) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // stats
|
||||
.mockRejectedValueOnce(new Error('network')) // resume
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: null }) // pulse
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // activity
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.incompleteDocs).toEqual([]);
|
||||
expect(result.resumeDoc).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults recentDocs to [] when recent-activity API rejects', async () => {
|
||||
it('defaults activityFeed to [] when activity API rejects', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
@@ -119,15 +145,16 @@ describe('home page load — dashboard mode', () => {
|
||||
response: { ok: true },
|
||||
data: { totalDocuments: 0, totalPersons: 0 }
|
||||
}) // stats
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||
.mockRejectedValueOnce(new Error('network')); // recent
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: null }) // resume
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: null }) // pulse
|
||||
.mockRejectedValueOnce(new Error('network')); // activity
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.recentDocs).toEqual([]);
|
||||
expect(result.activityFeed).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,8 +181,8 @@ describe('home page load — search mode', () => {
|
||||
expect(result.isDashboard).toBe(false);
|
||||
expect(result.documents).toHaveLength(1);
|
||||
expect(result.stats).toBeNull();
|
||||
expect(result.incompleteDocs).toEqual([]);
|
||||
expect(result.recentDocs).toEqual([]);
|
||||
expect(result.resumeDoc).toBeNull();
|
||||
expect(result.activityFeed).toEqual([]);
|
||||
// Only two API calls — no widget calls
|
||||
expect(mockGet).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -40,10 +40,10 @@ const emptyData = {
|
||||
string,
|
||||
import('$lib/generated/api').components['schemas']['SearchMatchData']
|
||||
>,
|
||||
incompleteDocs: [],
|
||||
recentDocs: [],
|
||||
resumeDoc: null,
|
||||
pulse: null,
|
||||
activityFeed: [],
|
||||
stats: null,
|
||||
incompleteCount: 0,
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
segmentationDocs: [],
|
||||
transcriptionDocs: [],
|
||||
@@ -233,33 +233,31 @@ describe('Home page – dashboard mode', () => {
|
||||
const dashboardData = {
|
||||
...emptyData,
|
||||
isDashboard: true,
|
||||
canWrite: false,
|
||||
incompleteDocs: [],
|
||||
recentDocs: []
|
||||
resumeDoc: null,
|
||||
pulse: null,
|
||||
activityFeed: []
|
||||
};
|
||||
|
||||
it('hides the right column when canWrite is false and incompleteDocs is empty', async () => {
|
||||
it('renders empty state when resumeDoc is null', async () => {
|
||||
render(Page, { data: dashboardData });
|
||||
const rightCol = page.getByTestId('dashboard-right-column');
|
||||
await expect.element(rightCol).not.toBeInTheDocument();
|
||||
const empty = page.getByTestId('resume-strip-empty');
|
||||
await expect.element(empty).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the right column when canWrite is true', async () => {
|
||||
render(Page, { data: { ...dashboardData, canWrite: true } });
|
||||
const rightCol = page.getByTestId('dashboard-right-column');
|
||||
await expect.element(rightCol).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the right column when incompleteDocs is non-empty', async () => {
|
||||
render(Page, {
|
||||
data: {
|
||||
...dashboardData,
|
||||
canWrite: false,
|
||||
incompleteDocs: [{ id: 'd1', title: 'Taufschein' }]
|
||||
}
|
||||
});
|
||||
const rightCol = page.getByTestId('dashboard-right-column');
|
||||
await expect.element(rightCol).toBeInTheDocument();
|
||||
it('renders resume card when resumeDoc is provided', async () => {
|
||||
const resume = {
|
||||
documentId: 'doc-1',
|
||||
title: 'Geburtsurkunde',
|
||||
caption: 'Max · 1920',
|
||||
excerpt: 'Hiermit…',
|
||||
page: 1,
|
||||
pages: 3,
|
||||
pct: 33,
|
||||
collaborators: []
|
||||
};
|
||||
render(Page, { data: { ...dashboardData, resumeDoc: resume } });
|
||||
const strip = page.getByTestId('resume-strip');
|
||||
await expect.element(strip).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user