Compare commits
115 Commits
feat/issue
...
676d3cb6a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
676d3cb6a7 | ||
|
|
d389dc2023 | ||
|
|
b4212f5e86 | ||
|
|
c22f2e41b1 | ||
|
|
7d2d615e0c | ||
|
|
4a88b3ba82 | ||
|
|
6dc81ef2e3 | ||
|
|
cef1810700 | ||
|
|
351f31b183 | ||
|
|
e6432846a1 | ||
|
|
a66bec1971 | ||
|
|
82d5a34f76 | ||
|
|
3d086bd1fb | ||
|
|
e384c87eef | ||
|
|
f09b605752 | ||
|
|
193bd73af1 | ||
|
|
cab017a2ce | ||
|
|
be4f1ed73b | ||
|
|
6475ebcc60 | ||
|
|
d8830b5a8e | ||
|
|
569a13e1b1 | ||
|
|
7ad852dd52 | ||
|
|
03d76863cb | ||
|
|
f3c29ffe58 | ||
|
|
8c26876345 | ||
|
|
da43cadb0a | ||
|
|
3b2d905041 | ||
|
|
7036f18b25 | ||
|
|
99e2e6e5c1 | ||
|
|
aaffee2804 | ||
|
|
18c6bca2dd | ||
|
|
d13f6f69d5 | ||
|
|
052f70e871 | ||
|
|
a3fbcf346b | ||
|
|
b21778b3d1 | ||
|
|
51c799e20e | ||
|
|
6463a32dfc | ||
|
|
1efd3d8e23 | ||
|
|
5211e0b9f7 | ||
|
|
234f83c40b | ||
|
|
a46b1a2e84 | ||
|
|
5231476c27 | ||
|
|
46d64f50a5 | ||
|
|
1a57ec2036 | ||
|
|
e362bc4977 | ||
|
|
01ba0d4121 | ||
|
|
2e6366faf7 | ||
|
|
9dd35999e0 | ||
|
|
e94f43264c | ||
|
|
da7f94de84 | ||
|
|
3f0b686963 | ||
|
|
1e9ef63191 | ||
|
|
51348ad26a | ||
|
|
dba1e2a8eb | ||
|
|
654b1283c1 | ||
|
|
c5b98af69b | ||
|
|
03e2382c8a | ||
|
|
528e1e05ea | ||
|
|
c64abccf63 | ||
|
|
47960b5028 | ||
|
|
7f2940f0f2 | ||
|
|
37d728b006 | ||
|
|
965087b787 | ||
|
|
1d2e6d7b86 | ||
|
|
0c40e10743 | ||
|
|
358131ca34 | ||
|
|
c7af33b998 | ||
|
|
eafb566170 | ||
|
|
624eb9e5d6 | ||
|
|
7bd995a045 | ||
|
|
20dbe04d45 | ||
|
|
c9211b3061 | ||
|
|
27254fb0ac | ||
|
|
b5a68e69e2 | ||
|
|
b1e959412f | ||
|
|
19035fbeab | ||
|
|
79faee554a | ||
|
|
5adef7bec5 | ||
|
|
595c2eb987 | ||
|
|
518019f099 | ||
|
|
38b8804b17 | ||
|
|
81ed1ce3ed | ||
|
|
92e7aa127c | ||
|
|
f618364632 | ||
|
|
20923d04b6 | ||
|
|
6d61297182 | ||
|
|
fb636e4152 | ||
|
|
527d174e9c | ||
|
|
f1bf32ee05 | ||
|
|
a5cc8fd16e | ||
|
|
1541afd470 | ||
|
|
d0deb26065 | ||
|
|
f04e4ffa8b | ||
|
|
17889df220 | ||
|
|
fe1121de65 | ||
|
|
2004a80055 | ||
|
|
f70b5ae6bd | ||
|
|
12b8324245 | ||
|
|
a9b648454e | ||
|
|
938a4b07bf | ||
|
|
7e43bd43a4 | ||
|
|
56926efd03 | ||
|
|
a6ee444f3b | ||
|
|
2dd73cf594 | ||
|
|
53038dea68 | ||
|
|
281934529e | ||
|
|
c905f136d2 | ||
|
|
36bf591afe | ||
|
|
550a9704ad | ||
|
|
55e681c209 | ||
|
|
e65ddc655e | ||
|
|
14b1cc7539 | ||
|
|
adc1f343b2 | ||
|
|
3dfaf69fb1 | ||
|
|
fd2a7a8e96 |
@@ -85,6 +85,37 @@ public class CommentController {
|
||||
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||
}
|
||||
|
||||
// ─── Block (transcription) comments ────────────────────────────────────────
|
||||
|
||||
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
|
||||
public List<DocumentComment> getBlockComments(@PathVariable UUID blockId) {
|
||||
return commentService.getCommentsForBlock(blockId);
|
||||
}
|
||||
|
||||
@PostMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
public DocumentComment postBlockComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser author = resolveUser(authentication);
|
||||
return commentService.postBlockComment(documentId, blockId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||
}
|
||||
|
||||
@PostMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments/{commentId}/replies")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
public DocumentComment replyToBlockComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID commentId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser author = resolveUser(authentication);
|
||||
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||
}
|
||||
|
||||
// ─── Edit and delete (shared) ─────────────────────────────────────────────
|
||||
|
||||
@PatchMapping("/api/documents/{documentId}/comments/{commentId}")
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.service.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/documents/{documentId}/transcription-blocks")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TranscriptionBlockController {
|
||||
|
||||
private final TranscriptionService transcriptionService;
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public List<TranscriptionBlock> listBlocks(@PathVariable UUID documentId) {
|
||||
return transcriptionService.listBlocks(documentId);
|
||||
}
|
||||
|
||||
@GetMapping("/{blockId}")
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public TranscriptionBlock getBlock(@PathVariable UUID documentId, @PathVariable UUID blockId) {
|
||||
return transcriptionService.getBlock(documentId, blockId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public TranscriptionBlock createBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@RequestBody CreateTranscriptionBlockDTO dto,
|
||||
Authentication authentication) {
|
||||
UUID userId = requireUserId(authentication);
|
||||
return transcriptionService.createBlock(documentId, dto, userId);
|
||||
}
|
||||
|
||||
@PutMapping("/{blockId}")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public TranscriptionBlock updateBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId,
|
||||
@RequestBody UpdateTranscriptionBlockDTO dto,
|
||||
Authentication authentication) {
|
||||
UUID userId = requireUserId(authentication);
|
||||
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{blockId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public void deleteBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId) {
|
||||
transcriptionService.deleteBlock(documentId, blockId);
|
||||
}
|
||||
|
||||
@PutMapping("/reorder")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public List<TranscriptionBlock> reorderBlocks(
|
||||
@PathVariable UUID documentId,
|
||||
@RequestBody ReorderTranscriptionBlocksDTO dto) {
|
||||
transcriptionService.reorderBlocks(documentId, dto);
|
||||
return transcriptionService.listBlocks(documentId);
|
||||
}
|
||||
|
||||
@GetMapping("/{blockId}/history")
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public List<TranscriptionBlockVersion> getBlockHistory(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId) {
|
||||
return transcriptionService.getBlockHistory(documentId, blockId);
|
||||
}
|
||||
|
||||
private UUID requireUserId(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
throw DomainException.unauthorized("Authentication required");
|
||||
}
|
||||
AppUser user = userService.findByUsername(authentication.getName());
|
||||
if (user == null) {
|
||||
throw DomainException.unauthorized("User not found");
|
||||
}
|
||||
return user.getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CreateTranscriptionBlockDTO {
|
||||
@Min(0)
|
||||
private int pageNumber;
|
||||
@Min(0)
|
||||
private double x;
|
||||
@Min(0)
|
||||
private double y;
|
||||
@Positive
|
||||
private double width;
|
||||
@Positive
|
||||
private double height;
|
||||
private String text;
|
||||
private String label;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ReorderTranscriptionBlocksDTO {
|
||||
private List<UUID> blockIds;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UpdateTranscriptionBlockDTO {
|
||||
private String text;
|
||||
private String label;
|
||||
}
|
||||
@@ -50,6 +50,12 @@ public enum ErrorCode {
|
||||
/** The new annotation overlaps an existing one on the same page. 409 */
|
||||
ANNOTATION_OVERLAP,
|
||||
|
||||
// --- Transcription Blocks ---
|
||||
/** The transcription block with the given ID does not exist. 404 */
|
||||
TRANSCRIPTION_BLOCK_NOT_FOUND,
|
||||
/** Optimistic locking conflict — block was modified by another user. 409 */
|
||||
TRANSCRIPTION_BLOCK_CONFLICT,
|
||||
|
||||
// --- Comments ---
|
||||
/** The comment with the given ID does not exist. 404 */
|
||||
COMMENT_NOT_FOUND,
|
||||
|
||||
@@ -33,6 +33,9 @@ public class DocumentComment {
|
||||
@Column(name = "annotation_id")
|
||||
private UUID annotationId;
|
||||
|
||||
@Column(name = "block_id")
|
||||
private UUID blockId;
|
||||
|
||||
@Column(name = "parent_id")
|
||||
private UUID parentId;
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "transcription_blocks")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class TranscriptionBlock {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "annotation_id", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID annotationId;
|
||||
|
||||
@Column(name = "document_id", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID documentId;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String text;
|
||||
|
||||
@Column(length = 200)
|
||||
private String label;
|
||||
|
||||
@Column(name = "sort_order", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private int sortOrder;
|
||||
|
||||
@Version
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private int version;
|
||||
|
||||
@Column(name = "created_by")
|
||||
private UUID createdBy;
|
||||
|
||||
@Column(name = "updated_by")
|
||||
private UUID updatedBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
@CreationTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
@UpdateTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "transcription_block_versions")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class TranscriptionBlockVersion {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "block_id", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID blockId;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String text;
|
||||
|
||||
@Column(name = "changed_by")
|
||||
private UUID changedBy;
|
||||
|
||||
@Column(name = "changed_at", nullable = false, updatable = false)
|
||||
@CreationTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime changedAt;
|
||||
}
|
||||
@@ -13,4 +13,6 @@ public interface CommentRepository extends JpaRepository<DocumentComment, UUID>
|
||||
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
|
||||
|
||||
List<DocumentComment> findByParentId(UUID parentId);
|
||||
|
||||
List<DocumentComment> findByBlockIdAndParentIdIsNull(UUID blockId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface TranscriptionBlockRepository extends JpaRepository<TranscriptionBlock, UUID> {
|
||||
|
||||
List<TranscriptionBlock> findByDocumentIdOrderBySortOrderAsc(UUID documentId);
|
||||
|
||||
Optional<TranscriptionBlock> findByIdAndDocumentId(UUID id, UUID documentId);
|
||||
|
||||
int countByDocumentId(UUID documentId);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface TranscriptionBlockVersionRepository extends JpaRepository<TranscriptionBlockVersion, UUID> {
|
||||
|
||||
List<TranscriptionBlockVersion> findByBlockIdOrderByChangedAtDesc(UUID blockId);
|
||||
}
|
||||
@@ -34,6 +34,28 @@ public class CommentService {
|
||||
return withRepliesAndMentions(roots);
|
||||
}
|
||||
|
||||
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
|
||||
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
|
||||
return withRepliesAndMentions(roots);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content,
|
||||
List<UUID> mentionedUserIds, AppUser author) {
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.documentId(documentId)
|
||||
.blockId(blockId)
|
||||
.content(content)
|
||||
.authorId(author.getId())
|
||||
.authorName(resolveAuthorName(author))
|
||||
.build();
|
||||
saveMentions(comment, mentionedUserIds);
|
||||
DocumentComment saved = commentRepository.save(comment);
|
||||
withMentionDTOs(saved);
|
||||
notificationService.notifyMentions(mentionedUserIds, saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentComment postComment(UUID documentId, UUID annotationId, String content,
|
||||
List<UUID> mentionedUserIds, AppUser author) {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||
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.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TranscriptionService {
|
||||
|
||||
private static final String TRANSCRIPTION_COLOR = "#00C7B1";
|
||||
private static final int MAX_TEXT_LENGTH = 10_000;
|
||||
|
||||
private final TranscriptionBlockRepository blockRepository;
|
||||
private final TranscriptionBlockVersionRepository versionRepository;
|
||||
private final AnnotationRepository annotationRepository;
|
||||
private final AnnotationService annotationService;
|
||||
private final DocumentService documentService;
|
||||
|
||||
public List<TranscriptionBlock> listBlocks(UUID documentId) {
|
||||
return blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
|
||||
}
|
||||
|
||||
public TranscriptionBlock getBlock(UUID documentId, UUID blockId) {
|
||||
return blockRepository.findByIdAndDocumentId(blockId, documentId)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND,
|
||||
"Transcription block not found: " + blockId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TranscriptionBlock createBlock(UUID documentId, CreateTranscriptionBlockDTO dto, UUID userId) {
|
||||
Document doc = documentService.getDocumentById(documentId);
|
||||
|
||||
CreateAnnotationDTO annotationDTO = new CreateAnnotationDTO(
|
||||
dto.getPageNumber(), dto.getX(), dto.getY(),
|
||||
dto.getWidth(), dto.getHeight(), TRANSCRIPTION_COLOR);
|
||||
DocumentAnnotation annotation = annotationService.createAnnotation(
|
||||
documentId, annotationDTO, userId, doc.getFileHash());
|
||||
|
||||
int nextOrder = blockRepository.countByDocumentId(documentId);
|
||||
String text = sanitizeText(dto.getText());
|
||||
|
||||
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||
.annotationId(annotation.getId())
|
||||
.documentId(documentId)
|
||||
.text(text)
|
||||
.label(dto.getLabel())
|
||||
.sortOrder(nextOrder)
|
||||
.createdBy(userId)
|
||||
.updatedBy(userId)
|
||||
.build();
|
||||
|
||||
TranscriptionBlock saved = blockRepository.save(block);
|
||||
saveVersion(saved, userId);
|
||||
log.info("Created transcription block {} for document {}", saved.getId(), documentId);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TranscriptionBlock updateBlock(UUID documentId, UUID blockId,
|
||||
UpdateTranscriptionBlockDTO dto, UUID userId) {
|
||||
TranscriptionBlock block = getBlock(documentId, blockId);
|
||||
|
||||
String text = sanitizeText(dto.getText());
|
||||
block.setText(text);
|
||||
if (dto.getLabel() != null) {
|
||||
block.setLabel(dto.getLabel());
|
||||
}
|
||||
block.setUpdatedBy(userId);
|
||||
|
||||
TranscriptionBlock saved = blockRepository.save(block);
|
||||
saveVersion(saved, userId);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteBlock(UUID documentId, UUID blockId) {
|
||||
TranscriptionBlock block = getBlock(documentId, blockId);
|
||||
UUID annotationId = block.getAnnotationId();
|
||||
|
||||
// Block is the aggregate root — delete block first (cascades to versions + comments),
|
||||
// then delete the dependent annotation directly (no ownership check needed)
|
||||
blockRepository.delete(block);
|
||||
blockRepository.flush();
|
||||
annotationRepository.deleteById(annotationId);
|
||||
log.info("Deleted transcription block {} and annotation {} for document {}",
|
||||
blockId, annotationId, documentId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void reorderBlocks(UUID documentId, ReorderTranscriptionBlocksDTO dto) {
|
||||
List<UUID> blockIds = dto.getBlockIds();
|
||||
for (int i = 0; i < blockIds.size(); i++) {
|
||||
TranscriptionBlock block = getBlock(documentId, blockIds.get(i));
|
||||
block.setSortOrder(i);
|
||||
blockRepository.save(block);
|
||||
}
|
||||
}
|
||||
|
||||
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
|
||||
getBlock(documentId, blockId);
|
||||
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);
|
||||
}
|
||||
|
||||
private void saveVersion(TranscriptionBlock block, UUID userId) {
|
||||
TranscriptionBlockVersion version = TranscriptionBlockVersion.builder()
|
||||
.blockId(block.getId())
|
||||
.text(block.getText())
|
||||
.changedBy(userId)
|
||||
.build();
|
||||
versionRepository.save(version);
|
||||
}
|
||||
|
||||
String sanitizeText(String text) {
|
||||
if (text == null) return "";
|
||||
if (text.length() > MAX_TEXT_LENGTH) {
|
||||
text = text.substring(0, MAX_TEXT_LENGTH);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE transcription_blocks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
annotation_id UUID NOT NULL REFERENCES document_annotations(id) ON DELETE RESTRICT,
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL DEFAULT '' CHECK (length(text) <= 10000),
|
||||
label VARCHAR(200),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tb_document_sort ON transcription_blocks(document_id, sort_order);
|
||||
CREATE INDEX idx_tb_annotation ON transcription_blocks(annotation_id);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE transcription_block_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
changed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
changed_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tbv_block ON transcription_block_versions(block_id, changed_at DESC);
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE document_comments
|
||||
ADD COLUMN block_id UUID REFERENCES transcription_blocks(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX idx_dc_block ON document_comments(block_id);
|
||||
@@ -279,4 +279,30 @@ class CommentControllerTest {
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
// ─── Block comment endpoints ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getBlockComments_returns200() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
when(commentService.getCommentsForBlock(blockId)).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void postBlockComment_returns201() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,4 +488,40 @@ class CommentServiceTest {
|
||||
.build()))
|
||||
.build();
|
||||
}
|
||||
|
||||
// ─── Block-level comments ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getCommentsForBlock_returnsRootCommentsFilteredByBlockId() {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
DocumentComment root = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).blockId(blockId).content("Nice work").authorName("Felix")
|
||||
.createdAt(LocalDateTime.now()).updatedAt(LocalDateTime.now()).build();
|
||||
when(commentRepository.findByBlockIdAndParentIdIsNull(blockId)).thenReturn(List.of(root));
|
||||
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of());
|
||||
|
||||
List<DocumentComment> result = commentService.getCommentsForBlock(blockId);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.getFirst().getContent()).isEqualTo("Nice work");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postBlockComment_setsBlockIdOnComment() {
|
||||
UUID documentId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("felix").firstName("Felix").lastName("Brandt").build();
|
||||
when(commentRepository.save(any())).thenAnswer(inv -> {
|
||||
DocumentComment c = inv.getArgument(0);
|
||||
c.setId(UUID.randomUUID());
|
||||
return c;
|
||||
});
|
||||
|
||||
DocumentComment result = commentService.postBlockComment(
|
||||
documentId, blockId, "Looks like Breslau", List.of(), author);
|
||||
|
||||
assertThat(result.getBlockId()).isEqualTo(blockId);
|
||||
assertThat(result.getDocumentId()).isEqualTo(documentId);
|
||||
assertThat(result.getContent()).isEqualTo("Looks like Breslau");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||
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.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TranscriptionServiceTest {
|
||||
|
||||
@Mock TranscriptionBlockRepository blockRepository;
|
||||
@Mock TranscriptionBlockVersionRepository versionRepository;
|
||||
@Mock AnnotationRepository annotationRepository;
|
||||
@Mock AnnotationService annotationService;
|
||||
@Mock DocumentService documentService;
|
||||
@InjectMocks TranscriptionService transcriptionService;
|
||||
|
||||
// ─── getBlock ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getBlock_throwsNotFound_whenBlockDoesNotExist() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> transcriptionService.getBlock(docId, blockId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBlock_returnsBlock_whenExists() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||
.id(blockId).documentId(docId).text("hello").build();
|
||||
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
||||
|
||||
TranscriptionBlock result = transcriptionService.getBlock(docId, blockId);
|
||||
|
||||
assertThat(result).isEqualTo(block);
|
||||
}
|
||||
|
||||
// ─── createBlock ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createBlock_createsAnnotationAndBlockAndVersion() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID annotId = UUID.randomUUID();
|
||||
|
||||
Document doc = Document.builder().id(docId).fileHash("hash123").build();
|
||||
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
||||
|
||||
DocumentAnnotation annotation = DocumentAnnotation.builder().id(annotId).build();
|
||||
when(annotationService.createAnnotation(eq(docId), any(CreateAnnotationDTO.class), eq(userId), eq("hash123")))
|
||||
.thenReturn(annotation);
|
||||
|
||||
when(blockRepository.countByDocumentId(docId)).thenReturn(0);
|
||||
when(blockRepository.save(any())).thenAnswer(inv -> {
|
||||
TranscriptionBlock b = inv.getArgument(0);
|
||||
b.setId(UUID.randomUUID());
|
||||
return b;
|
||||
});
|
||||
|
||||
CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null);
|
||||
|
||||
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
|
||||
|
||||
assertThat(result.getAnnotationId()).isEqualTo(annotId);
|
||||
assertThat(result.getText()).isEqualTo("hello");
|
||||
assertThat(result.getSortOrder()).isZero();
|
||||
assertThat(result.getCreatedBy()).isEqualTo(userId);
|
||||
verify(versionRepository).save(any(TranscriptionBlockVersion.class));
|
||||
}
|
||||
|
||||
// ─── updateBlock ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateBlock_updatesTextAndSavesVersion() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
|
||||
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||
.id(blockId).documentId(docId).text("old").build();
|
||||
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
||||
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null);
|
||||
|
||||
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
|
||||
|
||||
assertThat(result.getText()).isEqualTo("new text");
|
||||
assertThat(result.getUpdatedBy()).isEqualTo(userId);
|
||||
verify(versionRepository).save(any(TranscriptionBlockVersion.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateBlock_updatesLabel_whenProvided() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
|
||||
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||
.id(blockId).documentId(docId).text("text").label("old label").build();
|
||||
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
||||
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede");
|
||||
|
||||
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
||||
|
||||
assertThat(result.getLabel()).isEqualTo("Anrede");
|
||||
}
|
||||
|
||||
// ─── deleteBlock ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteBlock_deletesBlockAndAnnotation() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
UUID annotId = UUID.randomUUID();
|
||||
|
||||
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||
.id(blockId).documentId(docId).annotationId(annotId).build();
|
||||
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
||||
|
||||
transcriptionService.deleteBlock(docId, blockId);
|
||||
|
||||
verify(blockRepository).delete(block);
|
||||
verify(blockRepository).flush();
|
||||
verify(annotationRepository).deleteById(annotId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteBlock_throwsNotFound_whenBlockMissing() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> transcriptionService.deleteBlock(docId, blockId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||
}
|
||||
|
||||
// ─── reorderBlocks ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void reorderBlocks_updatesSortOrder() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
|
||||
TranscriptionBlock block1 = TranscriptionBlock.builder()
|
||||
.id(id1).documentId(docId).sortOrder(0).build();
|
||||
TranscriptionBlock block2 = TranscriptionBlock.builder()
|
||||
.id(id2).documentId(docId).sortOrder(1).build();
|
||||
|
||||
when(blockRepository.findByIdAndDocumentId(id2, docId)).thenReturn(Optional.of(block2));
|
||||
when(blockRepository.findByIdAndDocumentId(id1, docId)).thenReturn(Optional.of(block1));
|
||||
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
ReorderTranscriptionBlocksDTO dto = new ReorderTranscriptionBlocksDTO(List.of(id2, id1));
|
||||
|
||||
transcriptionService.reorderBlocks(docId, dto);
|
||||
|
||||
assertThat(block2.getSortOrder()).isZero();
|
||||
assertThat(block1.getSortOrder()).isEqualTo(1);
|
||||
}
|
||||
|
||||
// ─── getBlockHistory ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getBlockHistory_returnsVersionsForBlock() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
|
||||
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||
.id(blockId).documentId(docId).build();
|
||||
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
||||
|
||||
TranscriptionBlockVersion v = TranscriptionBlockVersion.builder()
|
||||
.id(UUID.randomUUID()).blockId(blockId).text("ver1").build();
|
||||
when(versionRepository.findByBlockIdOrderByChangedAtDesc(blockId)).thenReturn(List.of(v));
|
||||
|
||||
List<TranscriptionBlockVersion> result = transcriptionService.getBlockHistory(docId, blockId);
|
||||
|
||||
assertThat(result).containsExactly(v);
|
||||
}
|
||||
|
||||
// ─── sanitizeText ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void sanitizeText_returnsEmptyString_forNull() {
|
||||
assertThat(transcriptionService.sanitizeText(null)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeText_truncatesAtMaxLength() {
|
||||
String longText = "a".repeat(15_000);
|
||||
String result = transcriptionService.sanitizeText(longText);
|
||||
assertThat(result).hasSize(10_000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeText_preservesPlainText() {
|
||||
assertThat(transcriptionService.sanitizeText("Liebe Mutter,")).isEqualTo("Liebe Mutter,");
|
||||
}
|
||||
|
||||
// ─── listBlocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void listBlocks_returnsBlocksOrderedBySortOrder() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
TranscriptionBlock b = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).sortOrder(0).build();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of(b));
|
||||
|
||||
assertThat(transcriptionService.listBlocks(docId)).containsExactly(b);
|
||||
}
|
||||
}
|
||||
963
docs/specs/annotation-transcription-final-spec.html
Normal file
963
docs/specs/annotation-transcription-final-spec.html
Normal file
@@ -0,0 +1,963 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Annotation-Backed Transcription — Final Spec</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--accent-bg:rgba(161,220,216,.12);--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--purple-tint:#EEEDFE;--purple:#534AB7;--purple-dark:#3C3489;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--yellow-tint:#FDF6D8;--yellow-text:#8A6800;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||
|
||||
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||
.pill-g{background:var(--green-tint);color:var(--green-dark);}
|
||||
|
||||
.section{margin-bottom:64px;}
|
||||
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||
|
||||
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
|
||||
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
|
||||
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
|
||||
.jh-b{background:var(--blue-tint);border:1px solid #A4CFF4;}.jh-b .jn{color:var(--blue);}.jh-b p,.jh-b .fl{color:var(--blue-dark);}
|
||||
.jh-g{background:var(--green-tint);border:1px solid #A0D8A8;}.jh-g .jn{color:var(--green);}.jh-g p,.jh-g .fl{color:var(--green-dark);}
|
||||
.jh-o{background:var(--orange-tint);border:1px solid #F0C89A;}.jh-o .jn{color:var(--orange);}.jh-o p,.jh-o .fl{color:var(--orange-dark);}
|
||||
|
||||
.scr{margin-bottom:56px;}
|
||||
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||
|
||||
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||
|
||||
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;min-height:520px;}
|
||||
|
||||
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||
|
||||
/* ── FA chrome ── */
|
||||
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;}
|
||||
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
|
||||
.fa-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 12px;gap:6px;height:42px;flex-shrink:0;}
|
||||
.fa-topbar .back{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);}
|
||||
.fa-topbar .title{font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.fa-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px 1px 2px;background:var(--sand);border:1px solid #e4e2d7;border-radius:8px;white-space:nowrap;font-size:7px;color:var(--color-text);}
|
||||
.fa-chip .av{width:12px;height:12px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;flex-shrink:0;}
|
||||
.fa-chip .av.navy{background:var(--navy);color:var(--mint);}
|
||||
.fa-chip .av.purple{background:#5A3080;color:#fff;}
|
||||
.fa-topbar-btn{font-size:7px;font-weight:600;padding:3px 8px;border-radius:4px;border:1px solid var(--navy);color:var(--navy);background:transparent;display:flex;align-items:center;gap:3px;}
|
||||
.fa-topbar-btn.active{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||
.fa-topbar-btn.ghost{border-color:var(--color-border);color:var(--color-text-muted);font-weight:500;}
|
||||
.fa-topbar-btn.transcribe{background:var(--turquoise);color:var(--navy);border-color:var(--turquoise);font-weight:700;}
|
||||
|
||||
.details-toggle{display:inline-flex;align-items:center;gap:3px;padding:2px 8px 2px 6px;border-radius:4px;font-size:7px;font-weight:600;color:var(--color-text-muted);cursor:pointer;border:1px solid var(--color-border);background:transparent;white-space:nowrap;}
|
||||
|
||||
/* ── PDF + paper ── */
|
||||
.pdf-area{background:#D4D0C8;flex:1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;}
|
||||
.paper{background:#FFFEF8;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:9px 11px;display:flex;flex-direction:column;gap:2px;position:relative;}
|
||||
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px;}
|
||||
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px;}
|
||||
|
||||
/* ── Annotation rectangles on PDF ── */
|
||||
.ann-rect{position:absolute;border-radius:2px;pointer-events:auto;cursor:pointer;transition:all .15s ease;}
|
||||
.ann-rect.comment{border:1.5px solid rgba(255,200,0,.6);background:rgba(255,200,0,.15);}
|
||||
.ann-rect.comment:hover{background:rgba(255,200,0,.3);}
|
||||
.ann-rect.trans{border:1.5px solid var(--turquoise);background:rgba(0,199,177,.1);}
|
||||
.ann-rect.trans:hover{background:rgba(0,199,177,.2);}
|
||||
.ann-rect.trans.active{background:rgba(0,199,177,.25);box-shadow:0 0 0 2px var(--turquoise);}
|
||||
.ann-rect .ann-num{position:absolute;top:-8px;left:-8px;width:16px;height:16px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.3);}
|
||||
.ann-rect.trans .ann-num{background:var(--navy);}
|
||||
.ann-rect.comment .ann-num{background:var(--orange);}
|
||||
.ann-rect .ann-badge{position:absolute;bottom:-8px;right:-8px;background:var(--navy);color:#fff;font-size:6px;font-weight:700;padding:1px 4px;border-radius:8px;min-width:14px;text-align:center;box-shadow:0 1px 2px rgba(0,0,0,.3);}
|
||||
|
||||
/* ── Split + panels ── */
|
||||
.split{display:flex;flex:1;overflow:hidden;}
|
||||
.split-left{flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative;}
|
||||
.split-right{display:flex;flex-direction:column;overflow:hidden;border-left:1px solid #e4e2d7;}
|
||||
.split-handle{width:4px;background:var(--color-border);cursor:col-resize;flex-shrink:0;display:flex;align-items:center;justify-content:center;}
|
||||
.split-handle::after{content:'';width:2px;height:20px;background:var(--color-text-muted);border-radius:1px;opacity:.3;}
|
||||
|
||||
/* ── Transcript blocks ── */
|
||||
.tblock{margin-bottom:6px;border:1px solid var(--color-border);border-radius:5px;overflow:hidden;transition:all .15s ease;}
|
||||
.tblock.active{border-color:var(--turquoise);box-shadow:0 0 0 1px var(--turquoise);}
|
||||
.tblock.empty{border-style:dashed;opacity:.7;}
|
||||
.tblock-head{display:flex;align-items:center;gap:4px;padding:3px 8px;font-size:6px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-muted);}
|
||||
.tblock-head.active-bg{background:rgba(0,199,177,.08);}
|
||||
.tblock-head .num{width:14px;height:14px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;flex-shrink:0;}
|
||||
.tblock-body{padding:5px 8px;font-family:Georgia,serif;font-size:9px;line-height:1.65;color:var(--color-text);min-height:18px;}
|
||||
.tblock-body.editing{background:var(--color-page);cursor:text;}
|
||||
.tblock-body .illegible{color:var(--color-text-muted);font-style:italic;}
|
||||
.tblock-footer{display:flex;align-items:center;gap:4px;padding:2px 8px;border-top:1px solid var(--color-subtle);font-size:6px;color:var(--color-text-muted);}
|
||||
|
||||
.trans-cursor{display:inline-block;width:1px;height:10px;background:var(--blue);animation:blink 1s infinite;margin-left:1px;}
|
||||
@keyframes blink{0%,50%{opacity:1}51%,100%{opacity:0}}
|
||||
|
||||
/* ── Presence ── */
|
||||
.presence{display:flex;align-items:center;gap:3px;font-size:7px;color:var(--color-text-muted);}
|
||||
.presence-dot{width:5px;height:5px;border-radius:50%;}
|
||||
.hl-blue{border-left:2px solid var(--blue);padding-left:6px;background:rgba(45,125,210,.04);}
|
||||
.hl-purple{border-left:2px solid var(--purple);padding-left:6px;background:rgba(83,74,183,.04);}
|
||||
|
||||
/* ── Inline comment thread ── */
|
||||
.inline-thread{margin:3px 8px 5px;padding:5px 8px;border-radius:4px;border-left:2px solid var(--orange);background:var(--orange-tint);font-size:8px;color:var(--color-text);}
|
||||
.inline-thread .thread-head{font-size:6px;font-weight:600;color:var(--orange-dark);margin-bottom:2px;display:flex;align-items:center;gap:3px;}
|
||||
.inline-thread .thread-msg{display:flex;gap:3px;align-items:flex-start;margin-bottom:2px;}
|
||||
.inline-thread .thread-av{width:12px;height:12px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:#fff;flex-shrink:0;}
|
||||
.inline-thread .thread-reply{display:flex;gap:3px;margin-top:3px;}
|
||||
.inline-thread input{flex:1;font-size:7px;padding:2px 5px;border:1px solid var(--color-border);border-radius:3px;background:#fff;}
|
||||
.inline-thread .resolve-btn{font-size:6px;font-weight:600;color:var(--green-dark);padding:2px 5px;cursor:pointer;}
|
||||
|
||||
/* ── Hint strip ── */
|
||||
.hint-strip{display:flex;align-items:center;gap:6px;padding:0 12px;height:22px;border-top:1px dashed;flex-shrink:0;font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;}
|
||||
.hint-strip.trans-hint{background:rgba(0,199,177,.06);border-color:rgba(0,199,177,.3);color:var(--navy);}
|
||||
.hint-strip .hint-step{display:flex;align-items:center;gap:3px;font-weight:500;color:var(--color-text-muted);text-transform:none;letter-spacing:0;}
|
||||
|
||||
/* ── Transcript toolbar ── */
|
||||
.trans-toolbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 8px;gap:6px;flex-shrink:0;}
|
||||
.trans-toolbar-btn{font-size:6px;font-weight:600;padding:2px 6px;border-radius:3px;border:1px solid var(--color-border);color:var(--color-text-muted);background:transparent;cursor:pointer;display:flex;align-items:center;gap:2px;}
|
||||
.trans-toolbar-btn:hover{background:var(--sand);color:var(--color-text);}
|
||||
.trans-toolbar-btn.active{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||
|
||||
/* ── History panel (in-toolbar) ── */
|
||||
.history-panel{background:var(--color-page);border:1px solid var(--color-border);border-radius:5px;margin:4px 8px;padding:6px 8px;font-size:7px;}
|
||||
.history-entry{display:flex;align-items:center;gap:4px;padding:3px 0;border-bottom:1px solid var(--color-subtle);}
|
||||
.history-entry:last-child{border-bottom:none;}
|
||||
.history-entry .he-date{font-size:6px;color:var(--color-text-muted);min-width:40px;}
|
||||
.history-entry .he-user{font-size:6px;font-weight:600;color:var(--color-text);min-width:40px;}
|
||||
.history-entry .he-diff{font-size:7px;color:var(--color-text);}
|
||||
.he-add{background:var(--green-tint);color:var(--green-dark);padding:0 2px;border-radius:1px;}
|
||||
.he-del{background:#FEE2E2;color:#991B1B;padding:0 2px;border-radius:1px;text-decoration:line-through;}
|
||||
|
||||
/* ── Status bar ── */
|
||||
.status-bar{background:var(--sand);border-top:1px solid #e4e2d7;height:18px;display:flex;align-items:center;padding:0 8px;font-size:7px;color:var(--color-text-muted);gap:8px;flex-shrink:0;}
|
||||
.status-saved{color:var(--green-dark);}
|
||||
|
||||
/* ── Agent table ── */
|
||||
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
|
||||
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||
|
||||
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
|
||||
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
|
||||
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||
.llm li{margin-bottom:4px;}
|
||||
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.llm td{color:var(--color-text-muted);}
|
||||
|
||||
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<div class="doc-header">
|
||||
<div>
|
||||
<h1>Annotation-Backed Transcription</h1>
|
||||
<p>Final spec for the collaborative inline transcription system. Draw turquoise rectangles on the scanned letter → numbered transcript blocks appear in a side panel → type what you read. Block-level comment threads with quoted selections for discussion. History in the transcript toolbar. No bottom panel.</p>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
Familienarchiv<br/>
|
||||
<span class="pill pill-g">Final spec</span><br/>
|
||||
2026-04-04 · @leonievoss
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ CORE CONCEPT ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Core concept — Draw-to-Transcribe</div>
|
||||
<p class="prose">Today, annotations are rectangles on the PDF that open a comment thread in the side panel. By adding a <code>type</code> field to <code>DocumentAnnotation</code>, the same draw-a-rectangle gesture can create a <strong>transcription annotation</strong> (turquoise). A transcription annotation links a PDF region to an editable text block in the right panel.</p>
|
||||
<p class="prose">Comments live <strong>inside transcript blocks</strong> as block-level threads. Users can select a word or phrase before commenting — the selection is <strong>auto-quoted</strong> into the comment message (e.g. <code>> “Breslau”</code>) rather than structurally anchored to character offsets. This avoids fragile offset tracking that breaks when text is edited. The quote is a display hint, not a structural anchor. Yellow comment annotations are <strong>disabled in transcribe mode</strong> — only turquoise transcription rectangles appear on the PDF.</p>
|
||||
</div>
|
||||
|
||||
<div class="jh jh-b">
|
||||
<div class="jn">T</div>
|
||||
<div><h2>Draw-to-transcribe workflow</h2><p>Draw a rectangle around a passage on the scan. A transcript block appears in the editor, linked to that region. Type what you read. Rinse and repeat down the page. Others can join and work on different blocks simultaneously.</p><div class="fl">Reuses: AnnotationLayer + PdfViewer + CommentThread · New: TranscriptBlock + TranscriptEditor + type:transcription</div></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ WHAT STAYS / CHANGES / NEW ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">What stays, what changes, what’s new</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;font-size:12px;line-height:1.6;">
|
||||
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;">
|
||||
<div style="font-weight:600;color:var(--navy);margin-bottom:6px;">Reused as-is</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);">
|
||||
<li><code>AnnotationLayer</code> — draw rects on PDF</li>
|
||||
<li><code>PdfViewer</code> — render, zoom, page nav</li>
|
||||
<li><code>CommentThread</code> — threaded replies, mentions</li>
|
||||
<li><code>DocumentAnnotation</code> model — add <code>type</code> field</li>
|
||||
<li><code>DocumentComment</code> model — unchanged</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;">
|
||||
<div style="font-weight:600;color:var(--orange);margin-bottom:6px;">Repurposed</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);">
|
||||
<li><code>AnnotationSidePanel</code> slot → becomes the transcript editor panel</li>
|
||||
<li><code>annotateMode</code> state → split into <code>annotateMode</code> + <code>transcribeMode</code></li>
|
||||
<li>Annotation color → turquoise only in transcribe mode, yellow only in annotate mode (mutually exclusive)</li>
|
||||
<li><code>AnnotateHintStrip</code> → new copy for transcribe mode</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;">
|
||||
<div style="font-weight:600;color:var(--green);margin-bottom:6px;">New</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);">
|
||||
<li><code>transcription_blocks</code> table</li>
|
||||
<li>Transcript editor component (right panel)</li>
|
||||
<li>Block-level comment threads (quoted selections)</li>
|
||||
<li><code>type</code> column on <code>document_annotations</code></li>
|
||||
<li>History in transcript toolbar</li>
|
||||
<li>Bottom panel removed (all modes)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
SCREEN 1 — DESKTOP TRANSCRIBE MODE
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="desktop">
|
||||
<div class="scr-head"><h3>Desktop — transcribe mode active</h3><span class="scr-id">S1</span></div>
|
||||
<div class="scr-desc">Two users are collaborating. <strong>Only turquoise</strong> transcription rectangles appear on the PDF — no yellow comment annotations in transcribe mode. One user (Oma Inge, purple) is editing Block 2. The current user (blue) is editing Block 3. Block 2 has a comment thread where Oma Inge quoted “Breslau” to discuss the reading. Each block has a “Kommentieren” button in its footer. The transcript toolbar shows “Verlauf” (history). No bottom panel.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px</div>
|
||||
<div class="desk">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="fa-topbar">
|
||||
<div class="back">←</div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="presence" style="margin-right:4px;"><div class="presence-dot" style="background:var(--blue);"></div> Du</div>
|
||||
<div class="presence" style="margin-right:4px;"><div class="presence-dot" style="background:var(--purple);"></div> Oma Inge</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="details-toggle">Details ▼</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="fa-topbar-btn transcribe">✎ Transkribieren</div>
|
||||
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||
</div>
|
||||
|
||||
<!-- Hint strip -->
|
||||
<div class="hint-strip trans-hint">
|
||||
<span>Transkribieren</span>
|
||||
<span class="hint-step">— Markiere eine Textpassage im Scan, um einen Transkriptions-Block anzulegen</span>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:400px;">
|
||||
<!-- PDF with annotation rectangles -->
|
||||
<div class="split-left">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:240px;position:relative;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
|
||||
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
|
||||
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||
|
||||
<!-- Transcription annotations (turquoise) -->
|
||||
<div class="ann-rect trans" style="left:2%;top:0%;width:50%;height:10%;">
|
||||
<div class="ann-num">1</div>
|
||||
</div>
|
||||
<div class="ann-rect trans active" style="left:2%;top:14%;width:96%;height:32%;">
|
||||
<div class="ann-num">2</div>
|
||||
</div>
|
||||
<div class="ann-rect trans" style="left:2%;top:50%;width:96%;height:22%;">
|
||||
<div class="ann-num">3</div>
|
||||
</div>
|
||||
<div class="ann-rect trans" style="left:20%;top:80%;width:60%;height:12%;">
|
||||
<div class="ann-num">4</div>
|
||||
</div>
|
||||
|
||||
<!-- No yellow comment annotations in transcribe mode —
|
||||
only turquoise transcription rects on the PDF -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<!-- Transcript editor panel -->
|
||||
<div class="split-right" style="width:380px;">
|
||||
<!-- Transcript toolbar -->
|
||||
<div class="trans-toolbar">
|
||||
<span style="font-size:7px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||
<div style="flex:1;"></div>
|
||||
<div class="trans-toolbar-btn">☰ Sortieren</div>
|
||||
<div class="trans-toolbar-btn">🕑 Verlauf</div>
|
||||
<span style="font-size:7px;color:var(--green-dark);">✓ Gespeichert</span>
|
||||
</div>
|
||||
|
||||
<!-- Block list -->
|
||||
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
|
||||
|
||||
<!-- Block 1 — Greeting (done) -->
|
||||
<div class="tblock">
|
||||
<div class="tblock-head"><div class="num">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||
<div class="tblock-body">Liebe Martha,</div>
|
||||
</div>
|
||||
|
||||
<!-- Block 2 — Main body (edited by Oma Inge) -->
|
||||
<div class="tblock active">
|
||||
<div class="tblock-head active-bg">
|
||||
<div class="num">2</div> Hauptteil
|
||||
<div class="presence" style="margin-left:auto;"><div class="presence-dot" style="background:var(--purple);width:4px;height:4px;"></div> Oma Inge</div>
|
||||
</div>
|
||||
<div class="tblock-body editing hl-purple">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umständen entsprechend gut. Der Arzt sagt <span class="illegible">[unleserlich]</span> Wochen noch dauern wird.</div>
|
||||
|
||||
<!-- Block-level thread with quoted selection -->
|
||||
<div class="inline-thread">
|
||||
<div class="thread-head">💬 2 Kommentare</div>
|
||||
<div class="thread-msg">
|
||||
<div class="thread-av" style="background:var(--purple);">OI</div>
|
||||
<div>
|
||||
<strong style="font-size:7px;">Oma Inge</strong> · <span style="font-size:7px;color:var(--color-text-muted);">vor 12 Min.</span>
|
||||
<div style="border-left:2px solid var(--color-border);padding-left:4px;margin:2px 0;font-size:7px;font-style:italic;color:var(--color-text-muted);">“Breslau”</div>
|
||||
<div>Ich bin sicher, das ist “Breslau” — Heinrich war dort im Lazarett.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thread-msg">
|
||||
<div class="thread-av" style="background:var(--blue);">DU</div>
|
||||
<div>
|
||||
<strong style="font-size:7px;">Du</strong> · <span style="font-size:7px;color:var(--color-text-muted);">vor 8 Min.</span>
|
||||
<div>Stimmt, danke! Lass ich so.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thread-reply">
|
||||
<input placeholder="Antworten..."/>
|
||||
<div class="resolve-btn">✓ Lösen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block footer with comment button -->
|
||||
<div class="tblock-footer">
|
||||
<span style="cursor:pointer;color:var(--orange);display:flex;align-items:center;gap:2px;">💬 Kommentieren</span>
|
||||
<span style="margin-left:auto;font-size:5px;color:var(--color-text-muted);">Text markieren für Zitat</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block 3 — Family (edited by current user) -->
|
||||
<div class="tblock active" style="border-color:var(--blue);box-shadow:0 0 0 1px var(--blue);">
|
||||
<div class="tblock-head" style="background:rgba(45,125,210,.06);">
|
||||
<div class="num">3</div> Familie
|
||||
<div class="presence" style="margin-left:auto;"><div class="presence-dot" style="background:var(--blue);width:4px;height:4px;"></div> Du</div>
|
||||
</div>
|
||||
<div class="tblock-body editing hl-blue">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen.<span class="trans-cursor"></span></div>
|
||||
<div class="tblock-footer">
|
||||
<span style="cursor:pointer;color:var(--color-text-muted);display:flex;align-items:center;gap:2px;">💬 Kommentieren</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block 4 — Closing (done) -->
|
||||
<div class="tblock">
|
||||
<div class="tblock-head"><div class="num">4</div> Schluss <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||
<div class="tblock-body">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||
</div>
|
||||
|
||||
<!-- Add block CTA -->
|
||||
<div class="tblock empty" style="text-align:center;padding:8px;font-size:7px;color:var(--color-text-muted);cursor:pointer;">
|
||||
Markiere eine weitere Passage im Scan, um Block 5 anzulegen
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<span>Block 3 aktiv</span>
|
||||
<span>Oma Inge · Block 2</span>
|
||||
<span style="margin-left:auto;">1 offene Diskussion</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NO bottom panel in transcribe mode -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
SCREEN 2 — COMMENT FLOW: SELECT → QUOTE → DISCUSS
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="comment-flow">
|
||||
<div class="scr-head"><h3>Comment flow — select, quote, discuss</h3><span class="scr-id">S2</span></div>
|
||||
<div class="scr-desc">The user has selected “[unleserlich]” in Block 2 and clicked “Kommentieren”. The comment input opens with the selection auto-quoted. After posting, the comment appears in the thread with the quote displayed as an indented blockquote. This shows the full lifecycle: selection → quoted input → posted comment.</div>
|
||||
<div class="scr-var"><strong>Block-level threads + quoted selections</strong> — no char-offset anchoring, no fragile highlights. The quote is frozen text in the message body.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · comment input open with auto-quote</div>
|
||||
<div class="desk" style="min-height:380px;">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="fa-topbar">
|
||||
<div class="back">←</div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="details-toggle">Details ▼</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="fa-topbar-btn transcribe">✎ Transkribieren</div>
|
||||
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:290px;">
|
||||
<div class="split-left">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:160px;position:relative;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="ann-rect trans active" style="left:2%;top:14%;width:96%;height:40%;"><div class="ann-num">2</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<div class="split-right" style="width:380px;">
|
||||
<div class="trans-toolbar">
|
||||
<span style="font-size:7px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||
<div style="flex:1;"></div>
|
||||
<span style="font-size:7px;color:var(--green-dark);">✓ Gespeichert</span>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
|
||||
<!-- Block 2 with text selection + open comment input -->
|
||||
<div class="tblock active">
|
||||
<div class="tblock-head active-bg"><div class="num">2</div> Hauptteil</div>
|
||||
<div class="tblock-body editing">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen. Der Arzt sagt <span style="background:rgba(45,125,210,.25);border-radius:1px;padding:0 1px;">[unleserlich]</span> Wochen noch dauern wird.</div>
|
||||
|
||||
<!-- Comment input — open, with auto-quoted selection -->
|
||||
<div style="margin:0 8px 5px;padding:6px 8px;border-radius:4px;border:1px solid var(--orange);background:#fff;">
|
||||
<div style="font-size:6px;font-weight:600;color:var(--orange-dark);margin-bottom:3px;">Neuer Kommentar zu Block 2</div>
|
||||
<!-- Auto-quoted selection shown as editable blockquote -->
|
||||
<div style="border-left:2px solid var(--color-border);padding-left:4px;margin-bottom:3px;font-size:7px;font-style:italic;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;">
|
||||
> “[unleserlich]”
|
||||
<span style="font-size:5px;color:var(--color-text-muted);font-style:normal;cursor:pointer;margin-left:auto;">✕ Zitat entfernen</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:3px;">
|
||||
<input style="flex:1;font-size:7px;padding:3px 5px;border:1px solid var(--color-border);border-radius:3px;background:var(--color-page);" value='Könnte "sechs" oder "acht" sein. Wer hat die Originale?'/>
|
||||
<button style="font-size:6px;font-weight:600;padding:3px 8px;border-radius:3px;background:var(--navy);color:#fff;border:none;cursor:pointer;">Senden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tblock-footer">
|
||||
<span style="cursor:pointer;color:var(--orange);display:flex;align-items:center;gap:2px;font-weight:600;">💬 Kommentieren</span>
|
||||
<span style="margin-left:auto;font-size:5px;color:var(--color-text-muted);">Text markieren für Zitat</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block 3 — with an existing posted comment showing the quote -->
|
||||
<div class="tblock">
|
||||
<div class="tblock-head"><div class="num">3</div> Familie</div>
|
||||
<div class="tblock-body">Die Kinder sollen wissen, dass ich an sie denke.</div>
|
||||
|
||||
<!-- Posted comment thread with quoted selection -->
|
||||
<div class="inline-thread">
|
||||
<div class="thread-head">💬 1 Kommentar</div>
|
||||
<div class="thread-msg">
|
||||
<div class="thread-av" style="background:var(--purple);">OI</div>
|
||||
<div>
|
||||
<strong style="font-size:7px;">Oma Inge</strong> · <span style="font-size:7px;color:var(--color-text-muted);">vor 5 Min.</span>
|
||||
<div style="border-left:2px solid var(--color-border);padding-left:4px;margin:2px 0;font-size:7px;font-style:italic;color:var(--color-text-muted);">“Die Kinder”</div>
|
||||
<div>Fritz und Lotte. Fritz war damals 4, Lotte 7.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thread-reply">
|
||||
<input placeholder="Antworten..."/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tblock-footer">
|
||||
<span style="cursor:pointer;color:var(--color-text-muted);display:flex;align-items:center;gap:2px;">💬 Kommentieren</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar"><span>Block 2 aktiv</span><span style="margin-left:auto;">2 Kommentare</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>Comment flow · Select → Quote → Discuss</h4>
|
||||
<pre>/* Comment flow for block-level threads with quoted selections:
|
||||
*
|
||||
* 1. TRIGGER: user clicks "Kommentieren" in block footer.
|
||||
* Alternatively: Ctrl+Shift+K when block is focused.
|
||||
*
|
||||
* 2. AUTO-QUOTE: if text is selected in the block body (via mouse or keyboard),
|
||||
* the selection is captured and pre-filled as a blockquote in the comment input:
|
||||
* > "[unleserlich]"
|
||||
* The user can edit or remove the quote before sending (× button).
|
||||
* If no text was selected → input opens empty (general block comment).
|
||||
*
|
||||
* 3. STORAGE: the quote is stored as part of the comment `content` field.
|
||||
* Markdown blockquote syntax: "> \"Breslau\"\nI think this is Breslau."
|
||||
* The block_id FK on DocumentComment links the comment to its block.
|
||||
* NO char_offset_start/end columns. The quote is just text.
|
||||
*
|
||||
* 4. DISPLAY: the quote renders as an indented italic line with a left border,
|
||||
* above the comment text. It's visually distinct but structurally just content.
|
||||
*
|
||||
* 5. RESILIENCE: if the transcription text changes after quoting, nothing breaks.
|
||||
* The quote is a frozen snapshot. The discussion context is preserved.
|
||||
* Compare to char-offset anchoring where an edit would shift all offsets
|
||||
* and potentially point to the wrong text.
|
||||
*
|
||||
* 6. THREAD: replies to a quoted comment don't need their own quotes —
|
||||
* the parent comment provides context. Standard CommentThread reply flow.
|
||||
*
|
||||
* 7. MOBILE: "Kommentieren" button always visible in footer.
|
||||
* Selecting text → auto-quote works the same via touch selection.
|
||||
* Thread collapsed to "N Kommentare" row, tap to expand. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Comment input</td></tr>
|
||||
<tr><td>Container</td><td>border:orange, bg:white, radius:4px, mx:8px</td><td>Appears below block body, above footer</td></tr>
|
||||
<tr><td>Quote display</td><td>left-border:2px line, italic, 7px muted</td><td>Editable — user can modify or remove</td></tr>
|
||||
<tr><td>Remove quote</td><td>"× Zitat entfernen" link, 5px, top-right of quote</td><td>Converts to general block comment</td></tr>
|
||||
<tr><td>Input field</td><td>flex:1, 7px, border:line, bg:page, radius:3px</td><td>Auto-focuses when opened</td></tr>
|
||||
<tr><td>Send button</td><td>"Senden", 6px/600, navy bg, white text</td><td>Enter to send, Shift+Enter for newline</td></tr>
|
||||
<tr class="grp"><td colspan="3">Posted comment with quote</td></tr>
|
||||
<tr><td>Quote in thread</td><td>left-border:2px line, italic, 7px muted</td><td>Read-only — frozen snapshot of selected text</td></tr>
|
||||
<tr><td>Message below</td><td>8px normal text, below the quote</td><td>Standard CommentThread message styling</td></tr>
|
||||
<tr class="grp"><td colspan="3">Data model</td></tr>
|
||||
<tr><td>block_id</td><td>UUID FK → transcription_blocks (nullable)</td><td>Links comment to its block</td></tr>
|
||||
<tr><td>content</td><td>TEXT with markdown blockquote</td><td>> "quoted text"\nComment message</td></tr>
|
||||
<tr><td>No char offsets</td><td>—</td><td>Intentional. See spec rationale.</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
SCREEN 3 — HISTORY IN TRANSCRIPT TOOLBAR
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="history">
|
||||
<div class="scr-head"><h3>Desktop — history panel open</h3><span class="scr-id">S3</span></div>
|
||||
<div class="scr-desc">Clicking “Verlauf” in the transcript toolbar opens a collapsible history panel between the toolbar and the block list. Shows recent changes with word-level diffs, just like the existing <code>PanelHistory</code> component but embedded in the transcript panel instead of the bottom panel.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px · history open</div>
|
||||
<div class="desk" style="min-height:540px;">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="fa-topbar">
|
||||
<div class="back">←</div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="details-toggle">Details ▼</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="fa-topbar-btn transcribe">✎ Transkribieren</div>
|
||||
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||
</div>
|
||||
|
||||
<div class="hint-strip trans-hint">
|
||||
<span>Transkribieren</span>
|
||||
<span class="hint-step">— Markiere eine Textpassage im Scan</span>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:420px;">
|
||||
<div class="split-left">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:220px;position:relative;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div>
|
||||
<div class="ann-rect trans" style="left:2%;top:0%;width:50%;height:10%;"><div class="ann-num">1</div></div>
|
||||
<div class="ann-rect trans" style="left:2%;top:14%;width:96%;height:32%;"><div class="ann-num">2</div></div>
|
||||
<div class="ann-rect trans" style="left:2%;top:50%;width:96%;height:22%;"><div class="ann-num">3</div></div>
|
||||
<div class="ann-rect trans" style="left:20%;top:80%;width:60%;height:12%;"><div class="ann-num">4</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<div class="split-right" style="width:380px;">
|
||||
<!-- Toolbar with history active -->
|
||||
<div class="trans-toolbar">
|
||||
<span style="font-size:7px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||
<div style="flex:1;"></div>
|
||||
<div class="trans-toolbar-btn">☰ Sortieren</div>
|
||||
<div class="trans-toolbar-btn active">🕑 Verlauf</div>
|
||||
<span style="font-size:7px;color:var(--green-dark);">✓ Gespeichert</span>
|
||||
</div>
|
||||
|
||||
<!-- History panel (collapsible) -->
|
||||
<div class="history-panel">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;">
|
||||
<span style="font-size:6px;font-weight:600;color:var(--navy);text-transform:uppercase;letter-spacing:.06em;">Letzte Änderungen</span>
|
||||
<span style="font-size:6px;color:var(--color-text-muted);cursor:pointer;">Alle anzeigen →</span>
|
||||
</div>
|
||||
<div class="history-entry">
|
||||
<span class="he-date">14:23</span>
|
||||
<span class="he-user">Oma Inge</span>
|
||||
<span class="he-diff">Block 2: ...Lazarett in <span class="he-add">Breslau</span><span class="he-del">Bresla</span>...</span>
|
||||
</div>
|
||||
<div class="history-entry">
|
||||
<span class="he-date">14:18</span>
|
||||
<span class="he-user">Du</span>
|
||||
<span class="he-diff">Block 3: <span class="he-add">Die Kinder sollen wissen, dass ich an sie denke.</span></span>
|
||||
</div>
|
||||
<div class="history-entry">
|
||||
<span class="he-date">14:12</span>
|
||||
<span class="he-user">Oma Inge</span>
|
||||
<span class="he-diff">Block 2: <span class="he-add">ich schreibe Dir heute aus dem Lazarett</span></span>
|
||||
</div>
|
||||
<div class="history-entry">
|
||||
<span class="he-date">14:05</span>
|
||||
<span class="he-user">Du</span>
|
||||
<span class="he-diff">Block 1: <span class="he-add">Liebe Martha,</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocks below history -->
|
||||
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
|
||||
<div class="tblock">
|
||||
<div class="tblock-head"><div class="num">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||
<div class="tblock-body">Liebe Martha,</div>
|
||||
</div>
|
||||
<div class="tblock">
|
||||
<div class="tblock-head"><div class="num">2</div> Hauptteil</div>
|
||||
<div class="tblock-body">ich schreibe Dir heute aus dem Lazarett in Breslau...</div>
|
||||
</div>
|
||||
<div class="tblock active" style="border-color:var(--blue);box-shadow:0 0 0 1px var(--blue);">
|
||||
<div class="tblock-head" style="background:rgba(45,125,210,.06);"><div class="num">3</div> Familie</div>
|
||||
<div class="tblock-body editing hl-blue">Die Kinder sollen wissen...<span class="trans-cursor"></span></div>
|
||||
</div>
|
||||
<div class="tblock">
|
||||
<div class="tblock-head"><div class="num">4</div> Schluss <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||
<div class="tblock-body">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar"><span>Block 3 aktiv</span><span style="margin-left:auto;">✓ Gespeichert</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
SCREEN 3 — MOBILE TRANSCRIBE MODE
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="mobile">
|
||||
<div class="scr-head"><h3>Mobile — transcribe mode</h3><span class="scr-id">S4</span></div>
|
||||
<div class="scr-desc">On mobile, the PDF collapses to a 90px strip at the top. Annotation rectangles are visible as thin outlines. Transcript blocks stack vertically below. The history button is in the toolbar above the blocks. Inline threads expand in-place.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Mobile · 320px</div>
|
||||
<div class="phone" style="height:600px;">
|
||||
<div class="pst"><b>14:23</b><span>••• WiFi 🔋</span></div>
|
||||
<div class="pb">
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:6px 12px;display:flex;align-items:center;gap:6px;">
|
||||
<span style="font-size:11px;color:var(--color-text-muted);">←</span>
|
||||
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
|
||||
<span style="font-size:7px;font-weight:700;padding:2px 6px;border-radius:3px;background:var(--turquoise);color:var(--navy);">Transkr.</span>
|
||||
</div>
|
||||
<!-- PDF strip with annotations -->
|
||||
<div style="background:#D4D0C8;height:90px;display:flex;align-items:center;justify-content:center;position:relative;border-bottom:2px solid var(--turquoise);">
|
||||
<div style="background:#FFFEF8;width:45%;padding:6px 8px;box-shadow:0 1px 4px rgba(0,0,0,.12);border-radius:1px;position:relative;">
|
||||
<div style="font-size:5px;color:#8A8070;font-style:italic;opacity:.7;">Liebe Martha,</div>
|
||||
<div style="height:2px;background:#C4BDB0;opacity:.4;margin:2px 0;width:80%;"></div>
|
||||
<div style="height:1.5px;background:#C4BDB0;opacity:.25;margin:1px 0;width:90%;"></div>
|
||||
<div style="height:1.5px;background:#C4BDB0;opacity:.25;margin:1px 0;width:70%;"></div>
|
||||
<div style="position:absolute;left:2%;top:0;width:50%;height:18%;border:1px solid var(--turquoise);border-radius:1px;opacity:.5;"></div>
|
||||
<div style="position:absolute;left:2%;top:22%;width:96%;height:35%;border:1px solid var(--turquoise);border-radius:1px;background:rgba(0,199,177,.1);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Toolbar -->
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 12px;gap:4px;">
|
||||
<span style="font-size:8px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||
<div style="flex:1;"></div>
|
||||
<div style="font-size:7px;font-weight:600;padding:3px 6px;border-radius:3px;border:1px solid var(--color-border);color:var(--color-text-muted);">🕑 Verlauf</div>
|
||||
<span style="font-size:7px;color:var(--green-dark);">✓</span>
|
||||
</div>
|
||||
<!-- Block list -->
|
||||
<div style="flex:1;overflow-y:auto;padding:8px 12px;background:#fff;">
|
||||
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
|
||||
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">Liebe Martha,</div>
|
||||
</div>
|
||||
<div style="border:1px solid var(--turquoise);border-radius:5px;overflow:hidden;margin-bottom:6px;box-shadow:0 0 0 1px var(--turquoise);">
|
||||
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:rgba(0,199,177,.08);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">2</div> Hauptteil <span style="font-size:5px;color:var(--purple);margin-left:auto;">Oma Inge</span></div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;border-left:2px solid var(--purple);">ich schreibe Dir heute aus dem Lazarett in Breslau...</div>
|
||||
<!-- Inline thread (collapsed on mobile — tap to expand) -->
|
||||
<div style="padding:3px 8px;border-top:1px solid var(--color-subtle);display:flex;align-items:center;gap:3px;">
|
||||
<span style="font-size:7px;color:var(--orange);">💬</span>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">1 Diskussion · “Breslau”</span>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);margin-left:auto;">▼</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border:1px solid var(--blue);border-radius:5px;overflow:hidden;margin-bottom:6px;box-shadow:0 0 0 1px var(--blue);">
|
||||
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:rgba(45,125,210,.06);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">3</div> Familie <span style="font-size:5px;color:var(--blue);margin-left:auto;">Du</span></div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;border-left:2px solid var(--blue);">Die Kinder sollen wissen...<span class="trans-cursor"></span></div>
|
||||
</div>
|
||||
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
|
||||
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">4</div> Schluss <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ AGENT TABLES ═══ -->
|
||||
<div class="agent">
|
||||
<h4>Annotation-backed transcription · Core implementation spec</h4>
|
||||
<pre>/* Core flow: enter transcribe mode → crosshair cursor on PDF → draw rect → creates:
|
||||
* 1. DocumentAnnotation(type:"transcription", turquoise) in the DB
|
||||
* 2. TranscriptionBlock(annotation_id, text:"", sort_order:N) in the DB
|
||||
* 3. Editable block in the right panel, linked to the annotation
|
||||
* Clicking an annotation rect on PDF scrolls to + highlights the matching block.
|
||||
* Clicking a block header highlights the matching rect on PDF.
|
||||
*
|
||||
* COMMENTS: block-level threads with quoted selections.
|
||||
* - Each block has a "Kommentieren" button in its footer.
|
||||
* - If text is selected when clicking "Kommentieren", the selection is auto-quoted
|
||||
* into the comment (> "Breslau"). The quote is plain text in the message body,
|
||||
* NOT a structural char-offset anchor. It doesn't break when text changes.
|
||||
* - Threads are anchored to block_id only (no char offsets).
|
||||
* - Yellow comment annotations are DISABLED in transcribe mode.
|
||||
* Only turquoise transcription rects on the PDF. One annotation type per mode.
|
||||
*
|
||||
* History: "Verlauf" button in transcript toolbar toggles a collapsible panel
|
||||
* showing recent changes with word-level diffs per block.
|
||||
* Auto-save: debounced PATCH to /api/transcription-blocks/{blockId} (500ms).
|
||||
* Bottom panel: removed entirely (all modes). Metadata → topbar drawer. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Annotation reuse</td></tr>
|
||||
<tr><td>Draw gesture</td><td>Existing AnnotationLayer.onDraw(rect)</td><td>Same pointer events. crosshair cursor.</td></tr>
|
||||
<tr><td>Annotation color</td><td>turquoise (#00C7B1) for transcription</td><td>Yellow annotations disabled in transcribe mode</td></tr>
|
||||
<tr><td>Annotation type</td><td>New column: type VARCHAR "transcription"|"comment"</td><td>Default "comment" for backward compat</td></tr>
|
||||
<tr><td>Number badge</td><td>16px navy circle, top-left of rect</td><td>Sort order number, matches block number</td></tr>
|
||||
<tr class="grp"><td colspan="3">Transcript blocks (right panel)</td></tr>
|
||||
<tr><td>Block card</td><td>border:1px line, radius:5px, active: turquoise glow</td><td>Header: number + label + presence. Body: contenteditable.</td></tr>
|
||||
<tr><td>Block label</td><td>Editable text, defaults: Anrede, Hauptteil, Schluss</td><td>Double-click to rename</td></tr>
|
||||
<tr><td>Empty state</td><td>Dashed border, "noch leer" italic text</td><td>Focus to start typing</td></tr>
|
||||
<tr><td>Add block CTA</td><td>Dashed card: "Markiere eine Passage im Scan..."</td><td>Not clickable — directs user to draw on PDF</td></tr>
|
||||
<tr class="grp"><td colspan="3">Block-level comment threads</td></tr>
|
||||
<tr><td>Trigger</td><td>"Kommentieren" button in block footer</td><td>Always visible — no hover-reveal</td></tr>
|
||||
<tr><td>Quoted selection</td><td>If text selected → auto-quoted into comment body</td><td>Plain text quote (> "Breslau"), NOT char-offset anchor</td></tr>
|
||||
<tr><td>Quote display</td><td>Left border + italic, above the comment text</td><td>Decorative only — doesn't link to text range</td></tr>
|
||||
<tr><td>Thread UI</td><td>orange left-border, orange-tint bg, below block body</td><td>Block-level anchor (block_id). Reuses CommentThread.</td></tr>
|
||||
<tr><td>Footer hint</td><td>"Text markieren für Zitat" in 5px muted text</td><td>Only shown when block is active/focused</td></tr>
|
||||
<tr><td>Resolve</td><td>"✓ Lösen" button collapses thread</td><td>Resolved threads hidden by default, toggle to show</td></tr>
|
||||
<tr><td>Mobile</td><td>Threads collapsed to "2 Kommentare" row, tap to expand</td><td>Saves vertical space on small screens</td></tr>
|
||||
<tr class="grp"><td colspan="3">Yellow annotations in transcribe mode</td></tr>
|
||||
<tr><td>Status</td><td>Disabled — draw gesture only creates turquoise rects</td><td>Existing yellow annotations still visible (read-only)</td></tr>
|
||||
<tr><td>Annotate mode</td><td>Still available via topbar "Annotieren" button</td><td>Exits transcribe mode, enters annotate mode (yellow)</td></tr>
|
||||
<tr class="grp"><td colspan="3">History (transcript toolbar)</td></tr>
|
||||
<tr><td>Toggle</td><td>"🕗 Verlauf" button in transcript toolbar</td><td>Active state: navy bg, white text</td></tr>
|
||||
<tr><td>Panel</td><td>Collapsible, between toolbar and block list</td><td>bg:color-page, border:line, radius:5px</td></tr>
|
||||
<tr><td>Entries</td><td>Time + user + block ref + word-level diff</td><td>Reuses diffWords from 'diff' library</td></tr>
|
||||
<tr><td>"Alle anzeigen"</td><td>Link to full history view (reuses PanelHistory)</td><td>Opens in a modal or replaces block list temporarily</td></tr>
|
||||
<tr class="grp"><td colspan="3">Interaction</td></tr>
|
||||
<tr><td>Click rect → block</td><td>scrollIntoView + active state on block</td><td>Turquoise glow on both rect and block</td></tr>
|
||||
<tr><td>Click block → rect</td><td>PDF scrolls/zooms to show the annotation</td><td>If multi-page: switches page</td></tr>
|
||||
<tr><td>Delete block</td><td>Deletes annotation + block + threads</td><td>Confirm dialog if threads exist</td></tr>
|
||||
<tr><td>Reorder blocks</td><td>Drag handle in block header</td><td>Updates sort_order via PATCH</td></tr>
|
||||
<tr class="grp"><td colspan="3">Presence (collaborative)</td></tr>
|
||||
<tr><td>Dots in topbar</td><td>Colored dot + user name, flex row</td><td>Max 3 shown, "+N" overflow</td></tr>
|
||||
<tr><td>Block-level presence</td><td>Colored dot + name in block header</td><td>Left border color matches user</td></tr>
|
||||
<tr><td>Implementation</td><td>WebSocket presence via Y.js (future)</td><td>MVP: polling-based, 5s interval</td></tr>
|
||||
<tr class="grp"><td colspan="3">Auto-save</td></tr>
|
||||
<tr><td>Debounce</td><td>500ms after last keystroke</td><td>PATCH /api/transcription-blocks/{blockId}</td></tr>
|
||||
<tr><td>Status</td><td>"✓ Gespeichert" in toolbar, fades after 3s</td><td>"Speichern..." while request in-flight</td></tr>
|
||||
<tr><td>Conflict</td><td>Last-write-wins for MVP</td><td>Y.js CRDT for future collaborative editing</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||
<div class="llm">
|
||||
<h2>Implementation Guide — Annotation-Backed Transcription</h2>
|
||||
|
||||
<h3>1. Data Model Changes</h3>
|
||||
|
||||
<h4>Flyway migration: <code>document_annotations</code></h4>
|
||||
<ul>
|
||||
<li>Add <code>type VARCHAR(20) NOT NULL DEFAULT 'comment'</code>.</li>
|
||||
<li>Values: <code>'comment'</code> (existing behavior) or <code>'transcription'</code>.</li>
|
||||
<li>Backward compatible — all existing annotations default to <code>'comment'</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h4>New table: <code>transcription_blocks</code></h4>
|
||||
<table>
|
||||
<thead><tr><th>Column</th><th>Type</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>id</code></td><td>UUID PK</td><td>Generated</td></tr>
|
||||
<tr><td><code>annotation_id</code></td><td>UUID FK → document_annotations</td><td>Links block to its PDF rectangle</td></tr>
|
||||
<tr><td><code>document_id</code></td><td>UUID FK → documents</td><td>Denormalized for efficient queries</td></tr>
|
||||
<tr><td><code>text</code></td><td>TEXT</td><td>The transcription content</td></tr>
|
||||
<tr><td><code>label</code></td><td>VARCHAR(100)</td><td>"Anrede", "Hauptteil", etc.</td></tr>
|
||||
<tr><td><code>sort_order</code></td><td>INT</td><td>Display order in the editor</td></tr>
|
||||
<tr><td><code>created_by</code></td><td>UUID FK → app_users</td><td></td></tr>
|
||||
<tr><td><code>updated_by</code></td><td>UUID FK → app_users</td><td></td></tr>
|
||||
<tr><td><code>created_at</code></td><td>TIMESTAMP</td><td>@CreationTimestamp</td></tr>
|
||||
<tr><td><code>updated_at</code></td><td>TIMESTAMP</td><td>@UpdateTimestamp</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>Block-level comments: <code>document_comments</code></h4>
|
||||
<ul>
|
||||
<li>Add <code>block_id UUID FK → transcription_blocks</code> (nullable).</li>
|
||||
<li><strong>No char_offset columns.</strong> Quoted selections are stored as plain text in the comment <code>content</code> field using blockquote markdown syntax (<code>> “Breslau”</code>). This is intentional — char offsets break when text is edited and require OT/CRDT to maintain. Quotes are a display hint, not a structural anchor.</li>
|
||||
<li>Backward compatible — <code>block_id</code> is nullable, existing comments unaffected.</li>
|
||||
</ul>
|
||||
|
||||
<h4>Backward compatibility: <code>Document.transcription</code></h4>
|
||||
<p>The existing <code>transcription</code> TEXT field becomes a <strong>computed read-only view</strong>: <code>SELECT string_agg(text, E'\n\n' ORDER BY sort_order) FROM transcription_blocks WHERE document_id = ?</code>. Write operations go through the block API. This keeps search indexing, export, and the read-only <code>PanelTranscription</code> working without changes.</p>
|
||||
|
||||
<h3>2. Annotation Color Convention & Mode Exclusivity</h3>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Color</th><th>Hex</th><th>On click</th><th>When active</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Comment</td><td>Yellow</td><td><code>#FFC800</code></td><td>Opens AnnotationSidePanel (existing)</td><td>Annotate mode only</td></tr>
|
||||
<tr><td>Transcription</td><td>Turquoise</td><td><code>#00C7B1</code></td><td>Highlights matching block in transcript editor</td><td>Transcribe mode only</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><strong>Mode exclusivity:</strong> In transcribe mode, only turquoise rects can be drawn. Existing yellow comment annotations from annotate mode are still <em>visible</em> on the PDF (read-only, dimmed) but cannot be created or interacted with. The “Annotieren” button exits transcribe mode and enters annotate mode (and vice versa). This prevents overlapping annotation types and avoids user confusion about which comment system to use.</p>
|
||||
|
||||
<h3>3. Component Architecture</h3>
|
||||
<table>
|
||||
<thead><tr><th>Component</th><th>Change</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>AnnotationLayer.svelte</code></td><td>Pass <code>type</code> to <code>onDraw</code> callback. Render turquoise vs yellow based on annotation type. Add number badges for transcription annotations.</td></tr>
|
||||
<tr><td><code>PdfViewer.svelte</code></td><td>Split <code>handleAnnotationDraw</code> into two paths (annotate vs transcribe). Route <code>handleAnnotationClick</code> to either side panel or transcript editor.</td></tr>
|
||||
<tr><td><code>AnnotationSidePanel.svelte</code></td><td>No change — still handles comment-type annotations in annotate mode. Hidden in transcribe mode.</td></tr>
|
||||
<tr><td><code>TranscriptEditor.svelte</code> (new)</td><td>Right panel. Renders transcript toolbar + block list. Manages block CRUD, auto-save, block-level comment threads.</td></tr>
|
||||
<tr><td><code>TranscriptBlock.svelte</code> (new)</td><td>Single block card. contenteditable body, header with number/label/presence, footer with “Kommentieren” button, thread slot below body.</td></tr>
|
||||
<tr><td><code>BlockCommentThread.svelte</code> (new)</td><td>Comment thread anchored to a block. Shows quoted selections as blockquotes. Reuses <code>CommentThread</code> internally for replies/mentions.</td></tr>
|
||||
<tr><td><code>TranscriptToolbar.svelte</code> (new)</td><td>Block count, sort button, history toggle, save status.</td></tr>
|
||||
<tr><td><code>TranscriptHistory.svelte</code> (new)</td><td>Collapsible panel. Reuses <code>diffWords</code> from the <code>diff</code> library. Shows recent changes per block.</td></tr>
|
||||
<tr><td><code>DocumentBottomPanel.svelte</code></td><td>Removed entirely. Metadata lives in the topbar drawer (see companion spec). Discussion, transcription, and history are all inline.</td></tr>
|
||||
<tr><td><code>documents/[id]/+page.svelte</code></td><td>Add <code>transcribeMode</code> state. Conditionally render TranscriptEditor vs bottom panel.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>4. API Endpoints</h3>
|
||||
<table>
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>POST</td><td><code>/api/documents/{id}/annotations</code></td><td>Existing, but now accepts <code>type</code> field. If <code>type="transcription"</code>, also creates a TranscriptionBlock.</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/documents/{id}/transcription-blocks</code></td><td>Returns all blocks ordered by sort_order.</td></tr>
|
||||
<tr><td>PATCH</td><td><code>/api/transcription-blocks/{blockId}</code></td><td>Update text, label, or sort_order. Auto-save target.</td></tr>
|
||||
<tr><td>DELETE</td><td><code>/api/transcription-blocks/{blockId}</code></td><td>Deletes block + its annotation + any anchored comments.</td></tr>
|
||||
<tr><td>PATCH</td><td><code>/api/transcription-blocks/reorder</code></td><td>Bulk update sort_order for drag-and-drop reordering.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>5. Draw-to-Transcribe Workflow</h3>
|
||||
<ol>
|
||||
<li>User enters <strong>Transcribe mode</strong> (topbar button, turquoise). Hint strip appears. Yellow comment annotations become read-only/dimmed. Only turquoise rects can be drawn.</li>
|
||||
<li>Crosshair cursor on PDF (same as annotate mode). User draws a rectangle around a handwriting passage.</li>
|
||||
<li><code>AnnotationLayer.onDraw(rect)</code> fires. <code>PdfViewer</code> calls <code>POST /api/documents/{id}/annotations</code> with <code>type: "transcription"</code>.</li>
|
||||
<li>Backend creates <code>DocumentAnnotation</code> + <code>TranscriptionBlock</code> (empty text, next sort_order).</li>
|
||||
<li>Frontend receives the created annotation + block. The transcript editor scrolls to the new empty block and focuses it.</li>
|
||||
<li>User types the transcription. Auto-save debounces to <code>PATCH /api/transcription-blocks/{blockId}</code>.</li>
|
||||
<li>Repeat: draw next rectangle, type next block.</li>
|
||||
</ol>
|
||||
|
||||
<h3>6. Comment Flow — Block-Level Threads with Quoted Selections</h3>
|
||||
<p>Comments are anchored to <strong>blocks</strong>, not character offsets. This is a deliberate simplification:</p>
|
||||
|
||||
<h4>Why not char-offset anchoring?</h4>
|
||||
<ul>
|
||||
<li>When someone edits the transcription text, all character offsets downstream shift.</li>
|
||||
<li>Keeping offsets in sync requires operational transforms (OT) or CRDT — that’s the Y.js future work, not MVP.</li>
|
||||
<li>A stale offset pointing to the wrong word is worse than a quoted snippet that no longer matches but still shows what was discussed.</li>
|
||||
</ul>
|
||||
|
||||
<h4>How it works</h4>
|
||||
<ol>
|
||||
<li>User clicks <strong>“Kommentieren”</strong> in a block footer.</li>
|
||||
<li>If text is selected in the block body, the selection is <strong>auto-quoted</strong> into the comment input: <code>> “Breslau”</code>. The user can edit or remove the quote before sending.</li>
|
||||
<li>If no text is selected, the comment input opens empty — a general block-level comment.</li>
|
||||
<li>The comment is saved as a <code>DocumentComment</code> with <code>block_id</code> set. The quoted text is part of the <code>content</code> field (markdown blockquote syntax).</li>
|
||||
<li>The thread renders below the block body with an orange left-border. Quoted text appears as an indented italic blockquote above the comment message.</li>
|
||||
<li>Replies work the same as existing <code>CommentThread</code> — no changes needed.</li>
|
||||
</ol>
|
||||
|
||||
<h4>What happens when text changes after quoting?</h4>
|
||||
<p>Nothing breaks. The quote is a frozen snapshot of what the user selected. If “Bresla” was later corrected to “Breslau”, the original quote still reads <code>> “Bresla”</code> with Oma Inge’s comment “I think this is Breslau.” The context is preserved. No orphaned anchors, no broken highlights.</p>
|
||||
|
||||
<h4>Footer hint</h4>
|
||||
<p>When a block is focused/active, the footer shows a subtle hint: <em>“Text markieren für Zitat”</em> (select text for a quote). This teaches the quoted-selection pattern without requiring documentation.</p>
|
||||
|
||||
<h3>7. History in Transcript Toolbar</h3>
|
||||
<ul>
|
||||
<li>The “Verlauf” button in the toolbar toggles <code>TranscriptHistory.svelte</code>.</li>
|
||||
<li>The panel renders between the toolbar and the block list (pushes blocks down).</li>
|
||||
<li>It shows recent changes per block, using <code>diffWords</code> from the <code>diff</code> library (same as existing <code>PanelHistory</code>).</li>
|
||||
<li>Each entry: timestamp, user name, block reference (e.g. “Block 2”), and a word-level diff snippet.</li>
|
||||
<li>“Alle anzeigen” opens a full history view — can reuse the existing <code>PanelHistory</code> component in a modal.</li>
|
||||
<li>Data source: the existing document version history API, filtered/grouped by block.</li>
|
||||
</ul>
|
||||
|
||||
<h3>8. Accessibility</h3>
|
||||
<ul>
|
||||
<li>Transcription blocks: <code>role="region"</code> with <code>aria-label="Transkriptions-Block N: [label]"</code></li>
|
||||
<li>Block body: <code>contenteditable</code> with <code>aria-multiline="true"</code></li>
|
||||
<li>Number badges on PDF: <code>aria-label="Transkriptions-Bereich N"</code></li>
|
||||
<li>Comment button: <code>aria-label="Block N kommentieren"</code></li>
|
||||
<li>History toggle: <code>aria-expanded</code>, <code>aria-controls="transcript-history"</code></li>
|
||||
<li>Focus order: topbar → hint strip → PDF (for drawing) → transcript blocks (in sort order) → comment button → status bar</li>
|
||||
<li>Keyboard: Tab between blocks, Enter to edit, Escape to deselect. Ctrl+Shift+N to prompt draw on PDF. Ctrl+Shift+K to open comment on focused block.</li>
|
||||
</ul>
|
||||
|
||||
<h3>9. Companion Spec</h3>
|
||||
<p>The expandable metadata header (labeled “Details ▼” toggle) is specified separately in <code>expandable-metadata-header-spec.html</code>. Together, these two specs fully eliminate the bottom panel in <strong>all modes</strong>: metadata → header drawer, transcription → inline split view, discussion → inline threads, history → transcript toolbar. One consistent pattern — no mode-dependent UI structure.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
700
docs/specs/expandable-metadata-header-spec.html
Normal file
700
docs/specs/expandable-metadata-header-spec.html
Normal file
@@ -0,0 +1,700 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Expandable Metadata Header — Final Spec</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--accent-bg:rgba(161,220,216,.12);--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||
|
||||
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||
.pill-g{background:var(--green-tint);color:var(--green-dark);}
|
||||
|
||||
.section{margin-bottom:64px;}
|
||||
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||
|
||||
.scr{margin-bottom:56px;}
|
||||
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||
|
||||
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||
|
||||
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;min-height:520px;}
|
||||
|
||||
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||
|
||||
/* ── FA chrome ── */
|
||||
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;}
|
||||
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
|
||||
/* ── Topbar ── */
|
||||
.topbar{background:#fff;border-bottom:1px solid #e4e2d7;flex-shrink:0;position:relative;}
|
||||
.topbar-main{display:flex;align-items:center;padding:0 12px;gap:6px;height:42px;}
|
||||
.topbar .back{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);}
|
||||
.topbar .title{font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.topbar .date{font-size:8px;color:var(--color-text-muted);}
|
||||
.fa-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px 1px 2px;background:var(--sand);border:1px solid #e4e2d7;border-radius:8px;white-space:nowrap;font-size:7px;color:var(--color-text);}
|
||||
.fa-chip .av{width:12px;height:12px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;flex-shrink:0;}
|
||||
.fa-chip .av.navy{background:var(--navy);color:var(--mint);}
|
||||
.fa-chip .av.purple{background:#5A3080;color:#fff;}
|
||||
.fa-chip a{color:inherit;text-decoration:none;}
|
||||
.fa-chip a:hover{text-decoration:underline;}
|
||||
.fa-topbar-btn{font-size:7px;font-weight:600;padding:3px 8px;border-radius:4px;border:1px solid var(--navy);color:var(--navy);background:transparent;display:flex;align-items:center;gap:3px;cursor:pointer;}
|
||||
.fa-topbar-btn.active{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||
.fa-topbar-btn.transcribe{background:var(--turquoise);color:var(--navy);border-color:var(--turquoise);font-weight:700;}
|
||||
.fa-topbar-btn.ghost{border-color:var(--color-border);color:var(--color-text-muted);font-weight:500;}
|
||||
|
||||
/* ── Details toggle ── */
|
||||
.details-toggle{display:inline-flex;align-items:center;gap:3px;padding:2px 8px 2px 6px;border-radius:4px;font-size:7px;font-weight:600;color:var(--color-text-muted);cursor:pointer;border:1px solid var(--color-border);background:transparent;transition:all .15s ease;white-space:nowrap;}
|
||||
.details-toggle:hover{background:var(--sand);color:var(--color-text);}
|
||||
.details-toggle.open{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||
.details-toggle .chevron-icon{display:inline-block;font-size:7px;transition:transform .2s ease;}
|
||||
.details-toggle.open .chevron-icon{transform:rotate(180deg);}
|
||||
|
||||
/* ── PDF area ── */
|
||||
.pdf-area{background:#D4D0C8;flex:1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;}
|
||||
.paper{background:#FFFEF8;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:9px 11px;display:flex;flex-direction:column;gap:2px;position:relative;}
|
||||
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px;}
|
||||
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px;}
|
||||
|
||||
/* ── Annotation rects ── */
|
||||
.ann-rect{position:absolute;border:1.5px solid var(--turquoise);background:rgba(0,199,177,.1);border-radius:2px;}
|
||||
.ann-num{position:absolute;top:-8px;left:-8px;width:14px;height:14px;border-radius:50%;background:var(--navy);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;color:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);}
|
||||
|
||||
/* ── Transcript blocks ── */
|
||||
.tblock{margin-bottom:5px;border:1px solid var(--color-border);border-radius:5px;overflow:hidden;}
|
||||
.tblock.active{border-color:var(--turquoise);box-shadow:0 0 0 1px var(--turquoise);}
|
||||
.tblock-head{display:flex;align-items:center;gap:4px;padding:3px 8px;font-size:6px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-muted);}
|
||||
.tblock-head .num{width:14px;height:14px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;flex-shrink:0;}
|
||||
.tblock-body{padding:5px 8px;font-family:Georgia,serif;font-size:9px;line-height:1.65;color:var(--color-text);min-height:16px;}
|
||||
.trans-cursor{display:inline-block;width:1px;height:10px;background:var(--blue);animation:blink 1s infinite;margin-left:1px;}
|
||||
@keyframes blink{0%,50%{opacity:1}51%,100%{opacity:0}}
|
||||
|
||||
.split{display:flex;flex:1;overflow:hidden;}
|
||||
.split-handle{width:4px;background:var(--color-border);cursor:col-resize;flex-shrink:0;display:flex;align-items:center;justify-content:center;}
|
||||
.split-handle::after{content:'';width:2px;height:20px;background:var(--color-text-muted);border-radius:1px;opacity:.3;}
|
||||
|
||||
.status-bar{background:var(--sand);border-top:1px solid #e4e2d7;height:18px;display:flex;align-items:center;padding:0 8px;font-size:7px;color:var(--color-text-muted);gap:8px;flex-shrink:0;}
|
||||
|
||||
/* ── Metadata display elements ── */
|
||||
.meta-icon{width:14px;height:14px;display:flex;align-items:center;justify-content:center;font-size:9px;opacity:.5;flex-shrink:0;}
|
||||
.meta-label{font-size:5px;font-weight:600;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;}
|
||||
.meta-value{font-family:Georgia,serif;font-size:8px;color:var(--color-text);}
|
||||
.meta-value a{color:var(--navy);text-decoration:none;}.meta-value a:hover{text-decoration:underline;}
|
||||
.tag-chip{display:inline-block;font-size:6px;font-weight:600;padding:1px 5px;border-radius:3px;background:var(--sand);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.04em;cursor:pointer;}
|
||||
.tag-chip:hover{background:var(--navy);color:#fff;}
|
||||
|
||||
/* ── Person card (for expanded metadata) ── */
|
||||
.person-card{display:flex;align-items:center;gap:5px;padding:4px 6px;border:1px solid var(--color-border);border-radius:5px;background:var(--color-page);cursor:pointer;transition:all .1s;}
|
||||
.person-card:hover{border-color:var(--mint);background:var(--accent-bg);}
|
||||
.person-card .pc-av{width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;flex-shrink:0;}
|
||||
.person-card .pc-name{font-family:Georgia,serif;font-size:8px;color:var(--color-text);}
|
||||
.person-card .pc-alias{font-size:6px;color:var(--color-text-muted);}
|
||||
.person-card .pc-action{font-size:7px;color:var(--color-text-muted);margin-left:auto;opacity:0;transition:opacity .1s;}
|
||||
.person-card:hover .pc-action{opacity:1;}
|
||||
|
||||
/* ── Agent table ── */
|
||||
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
|
||||
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||
|
||||
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
|
||||
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||
.llm li{margin-bottom:4px;}
|
||||
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.llm td{color:var(--color-text-muted);}
|
||||
|
||||
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<div class="doc-header">
|
||||
<div>
|
||||
<h1>Expandable Metadata Header</h1>
|
||||
<p>The document topbar gains a labeled toggle button (<strong>“Details ▼”</strong>) that opens a full-width metadata drawer below the main row. This replaces the bottom panel’s Metadata tab in transcribe mode, keeping all interactive elements (person links, conversation links, tag filters) accessible without consuming permanent viewport space.</p>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
Familienarchiv<br/>
|
||||
<span class="pill pill-g">Final spec</span><br/>
|
||||
2026-04-04 · @leonievoss
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ DESIGN DECISION ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Why a labeled toggle, not just a chevron</div>
|
||||
<p class="prose">User interviews include family members aged 60+. A bare 12–16px chevron icon is easy to miss or misinterpret as decorative. A labeled button — <strong>“Details ▼”</strong> — is self-explanatory, provides a larger click target (min 44×28px), and follows the progressive disclosure pattern: key facts (title, date, person chips) are always visible in the topbar; the toggle reveals the full metadata only when needed.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">What lives where</div>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;">
|
||||
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:200px;">
|
||||
<div style="font-weight:600;color:var(--navy);margin-bottom:4px;">Always visible in topbar</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
|
||||
<li>Document title (truncated)</li>
|
||||
<li>Date (compact format)</li>
|
||||
<li>Sender & receiver chips (abbreviated)</li>
|
||||
<li>Action buttons (Edit, Annotate, Download)</li>
|
||||
<li><strong>“Details”</strong> toggle button</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:200px;">
|
||||
<div style="font-weight:600;color:var(--orange);margin-bottom:4px;">Revealed in drawer</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
|
||||
<li>Full date (long format)</li>
|
||||
<li>Creation location (e.g. “Breslau”)</li>
|
||||
<li>Archive location (e.g. “Ordner A3, Schublade 2”)</li>
|
||||
<li>Tags (clickable → filter documents)</li>
|
||||
<li>Full person cards with avatar, name, alias</li>
|
||||
<li>Person detail links (/persons/{id})</li>
|
||||
<li>Conversation links (/korrespondenz?...)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
DESKTOP — COLLAPSED
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="collapsed">
|
||||
<div class="scr-head"><h3>Desktop — collapsed (default)</h3><span class="scr-id">S1</span></div>
|
||||
<div class="scr-desc">The topbar looks identical to today except for the “Details” button between the person chips and the action buttons. The split view gets the full remaining viewport.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px · collapsed</div>
|
||||
<div class="desk" style="min-height:480px;">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="topbar">
|
||||
<div class="topbar-main">
|
||||
<div class="back">←</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 2px;"></div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="fa-chip"><div class="av navy">HR</div> <a href="#">Heinrich R.</a></div>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||
<div class="fa-chip"><div class="av purple">MR</div> <a href="#">Martha R.</a></div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<!-- Labeled toggle button -->
|
||||
<div class="details-toggle">Details <span class="chevron-icon">▼</span></div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="fa-topbar-btn transcribe">✎ Transkribieren</div>
|
||||
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Split view fills remaining space -->
|
||||
<div class="split" style="flex:1;">
|
||||
<div style="flex:1;">
|
||||
<div class="pdf-area" style="height:100%;">
|
||||
<div class="paper" style="width:50%;min-height:180px;position:relative;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div>
|
||||
<div class="ann-rect" style="left:2%;top:0%;width:50%;height:12%;"><div class="ann-num">1</div></div>
|
||||
<div class="ann-rect" style="left:2%;top:16%;width:96%;height:35%;"><div class="ann-num">2</div></div>
|
||||
<div class="ann-rect" style="left:2%;top:55%;width:96%;height:25%;"><div class="ann-num">3</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split-handle"></div>
|
||||
<div style="width:360px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;">
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 8px;gap:4px;flex-shrink:0;">
|
||||
<span style="font-size:7px;font-weight:600;color:var(--navy);">3 Blöcke</span>
|
||||
<div style="flex:1;"></div>
|
||||
<span style="font-size:7px;color:var(--green-dark);">✓ Gespeichert</span>
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
|
||||
<div class="tblock"><div class="tblock-head"><div class="num">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">✓</span></div><div class="tblock-body">Liebe Martha,</div></div>
|
||||
<div class="tblock active"><div class="tblock-head" style="background:rgba(0,199,177,.08);"><div class="num">2</div> Hauptteil</div><div class="tblock-body">ich schreibe Dir heute aus dem Lazarett in Breslau...<span class="trans-cursor"></span></div></div>
|
||||
<div class="tblock"><div class="tblock-head"><div class="num">3</div> Familie</div><div class="tblock-body" style="color:var(--color-text-muted);font-style:italic;">noch leer</div></div>
|
||||
</div>
|
||||
<div class="status-bar"><span>Block 2 aktiv</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
DESKTOP — EXPANDED
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="expanded">
|
||||
<div class="scr-head"><h3>Desktop — expanded</h3><span class="scr-id">S2</span></div>
|
||||
<div class="scr-desc">Clicking “Details” slides open a full-width drawer below the topbar. Three-column grid: details (date, location, archive), persons (sender & receiver cards with conversation links), and tags. The drawer <strong>pushes content down</strong> — it is part of the document flow, not an overlay. No clipping, no z-index issues.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px · expanded</div>
|
||||
<div class="desk" style="min-height:540px;">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="topbar" style="border-bottom:none;">
|
||||
<div class="topbar-main">
|
||||
<div class="back">←</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 2px;"></div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="fa-chip"><div class="av navy">HR</div> <a href="#">Heinrich R.</a></div>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||
<div class="fa-chip"><div class="av purple">MR</div> <a href="#">Martha R.</a></div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<!-- Toggle: active state -->
|
||||
<div class="details-toggle open">Details <span class="chevron-icon">▼</span></div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="fa-topbar-btn transcribe">✎ Transkribieren</div>
|
||||
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded metadata drawer -->
|
||||
<div style="border-top:1px solid #e4e2d7;padding:10px 16px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;background:var(--color-page);">
|
||||
<!-- Column 1: Details -->
|
||||
<div>
|
||||
<div class="meta-label" style="margin-bottom:5px;">Details</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
<div style="display:flex;align-items:center;gap:5px;">
|
||||
<div class="meta-icon">📅</div>
|
||||
<div><div class="meta-value">14. Mai 1943</div><div class="meta-label">Datum</div></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;">
|
||||
<div class="meta-icon">📍</div>
|
||||
<div><div class="meta-value">Breslau</div><div class="meta-label">Entstehungsort</div></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;">
|
||||
<div class="meta-icon">📁</div>
|
||||
<div><div class="meta-value">Ordner A3, Schublade 2</div><div class="meta-label">Archivstandort</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 2: Persons -->
|
||||
<div>
|
||||
<div class="meta-label" style="margin-bottom:5px;">Personen</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||
<div class="meta-label" style="margin-top:2px;">Absender</div>
|
||||
<div class="person-card">
|
||||
<div class="pc-av" style="background:var(--navy);color:var(--mint);">HR</div>
|
||||
<div><div class="pc-name">Heinrich Raddatz</div><div class="pc-alias">Opa Heinrich</div></div>
|
||||
<div class="pc-action">💬</div>
|
||||
</div>
|
||||
<div class="meta-label" style="margin-top:4px;">Empfänger</div>
|
||||
<div class="person-card">
|
||||
<div class="pc-av" style="background:#5A3080;color:#fff;">MR</div>
|
||||
<div><div class="pc-name">Martha Raddatz</div><div class="pc-alias">Oma Martha</div></div>
|
||||
<div class="pc-action">💬</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 3: Tags -->
|
||||
<div>
|
||||
<div class="meta-label" style="margin-bottom:5px;">Schlagwörter</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:3px;">
|
||||
<div class="tag-chip">Feldpost</div>
|
||||
<div class="tag-chip">2. Weltkrieg</div>
|
||||
<div class="tag-chip">Lazarett</div>
|
||||
<div class="tag-chip">Breslau</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid #e4e2d7;"></div>
|
||||
|
||||
<!-- Split view (pushed down) -->
|
||||
<div class="split" style="flex:1;">
|
||||
<div style="flex:1;">
|
||||
<div class="pdf-area" style="height:100%;">
|
||||
<div class="paper" style="width:50%;min-height:140px;position:relative;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split-handle"></div>
|
||||
<div style="width:360px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;">
|
||||
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
|
||||
<div class="tblock"><div class="tblock-head"><div class="num">1</div> Anrede</div><div class="tblock-body">Liebe Martha,</div></div>
|
||||
<div class="tblock active"><div class="tblock-head" style="background:rgba(0,199,177,.08);"><div class="num">2</div> Hauptteil</div><div class="tblock-body">ich schreibe Dir heute...<span class="trans-cursor"></span></div></div>
|
||||
</div>
|
||||
<div class="status-bar"><span>Block 2 aktiv</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
MOBILE — COLLAPSED
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="mobile-collapsed">
|
||||
<div class="scr-head"><h3>Mobile — collapsed</h3><span class="scr-id">S3</span></div>
|
||||
<div class="scr-desc">On mobile, the topbar shows the title, a compact “Details” toggle, and the transcribe mode pill. Person chips are hidden (shown in drawer instead). The toggle provides a 44px tap target.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Mobile · 320px · collapsed</div>
|
||||
<div class="phone" style="height:560px;">
|
||||
<div class="pst"><b>14:23</b><span>••• WiFi 🔋</span></div>
|
||||
<div class="pb">
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:6px 12px;">
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<span style="font-size:11px;color:var(--color-text-muted);">←</span>
|
||||
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
|
||||
<div class="details-toggle" style="font-size:8px;padding:4px 10px 4px 8px;min-height:28px;">Details <span class="chevron-icon" style="font-size:8px;">▼</span></div>
|
||||
<span style="font-size:7px;font-weight:700;padding:2px 6px;border-radius:3px;background:var(--turquoise);color:var(--navy);">Transkr.</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- PDF strip -->
|
||||
<div style="background:#D4D0C8;height:110px;display:flex;align-items:center;justify-content:center;border-bottom:2px solid var(--turquoise);">
|
||||
<div style="background:#FFFEF8;width:40%;padding:6px 8px;box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:1px;position:relative;">
|
||||
<div style="font-size:5px;color:#8A8070;font-style:italic;opacity:.7;">Liebe Martha,</div>
|
||||
<div style="height:2px;background:#C4BDB0;opacity:.4;margin:2px 0;width:80%;"></div>
|
||||
<div style="height:1.5px;background:#C4BDB0;opacity:.2;margin:1px 0;width:90%;"></div>
|
||||
<div style="height:1.5px;background:#C4BDB0;opacity:.2;margin:1px 0;width:70%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Transcript blocks -->
|
||||
<div style="flex:1;overflow-y:auto;padding:8px 12px;background:#fff;">
|
||||
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
|
||||
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">Liebe Martha,</div>
|
||||
</div>
|
||||
<div style="border:1px solid var(--turquoise);border-radius:5px;overflow:hidden;margin-bottom:6px;box-shadow:0 0 0 1px var(--turquoise);">
|
||||
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:rgba(0,199,177,.08);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">2</div> Hauptteil</div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">ich schreibe Dir heute aus dem Lazarett in Breslau...<span class="trans-cursor"></span></div>
|
||||
</div>
|
||||
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
|
||||
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">3</div> Familie</div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;color:var(--color-text-muted);font-style:italic;">noch leer</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
MOBILE — EXPANDED
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="mobile-expanded">
|
||||
<div class="scr-head"><h3>Mobile — expanded</h3><span class="scr-id">S4</span></div>
|
||||
<div class="scr-desc">The drawer opens as a single-column stack below the topbar. Person cards are full-width with 44px minimum touch targets. Conversation links are always visible (no hover-reveal on touch). Tags wrap naturally. The PDF strip and transcript blocks are pushed down.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Mobile · 320px · expanded</div>
|
||||
<div class="phone" style="height:620px;">
|
||||
<div class="pst"><b>14:23</b><span>••• WiFi 🔋</span></div>
|
||||
<div class="pb">
|
||||
<div style="background:#fff;border-bottom:none;padding:6px 12px;">
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<span style="font-size:11px;color:var(--color-text-muted);">←</span>
|
||||
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
|
||||
<div class="details-toggle open" style="font-size:8px;padding:4px 10px 4px 8px;min-height:28px;">Details <span class="chevron-icon" style="font-size:8px;">▼</span></div>
|
||||
<span style="font-size:7px;font-weight:700;padding:2px 6px;border-radius:3px;background:var(--turquoise);color:var(--navy);">Transkr.</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Expanded drawer: single-column -->
|
||||
<div style="background:var(--color-page);border-bottom:1px solid #e4e2d7;padding:10px 12px;">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
|
||||
<div style="flex:1;min-width:100px;">
|
||||
<div class="meta-label">Datum</div>
|
||||
<div class="meta-value">14. Mai 1943</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:100px;">
|
||||
<div class="meta-label">Ort</div>
|
||||
<div class="meta-value">Breslau</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
|
||||
<div style="flex:1;min-width:100px;">
|
||||
<div class="meta-label">Archivstandort</div>
|
||||
<div class="meta-value">Ordner A3, Schublade 2</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-label" style="margin-bottom:3px;">Absender</div>
|
||||
<div class="person-card" style="margin-bottom:4px;min-height:36px;">
|
||||
<div class="pc-av" style="background:var(--navy);color:var(--mint);">HR</div>
|
||||
<div><div class="pc-name">Heinrich Raddatz</div><div class="pc-alias">Opa Heinrich</div></div>
|
||||
<div class="pc-action" style="opacity:1;">💬</div>
|
||||
</div>
|
||||
<div class="meta-label" style="margin-bottom:3px;">Empfänger</div>
|
||||
<div class="person-card" style="margin-bottom:6px;min-height:36px;">
|
||||
<div class="pc-av" style="background:#5A3080;color:#fff;">MR</div>
|
||||
<div><div class="pc-name">Martha Raddatz</div><div class="pc-alias">Oma Martha</div></div>
|
||||
<div class="pc-action" style="opacity:1;">💬</div>
|
||||
</div>
|
||||
<div class="meta-label" style="margin-bottom:3px;">Schlagwörter</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:3px;">
|
||||
<div class="tag-chip">Feldpost</div>
|
||||
<div class="tag-chip">2. Weltkrieg</div>
|
||||
<div class="tag-chip">Lazarett</div>
|
||||
<div class="tag-chip">Breslau</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- PDF strip (pushed down) -->
|
||||
<div style="background:#D4D0C8;height:70px;display:flex;align-items:center;justify-content:center;border-bottom:2px solid var(--turquoise);">
|
||||
<div style="background:#FFFEF8;width:40%;padding:4px 6px;box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:1px;">
|
||||
<div style="height:2px;background:#C4BDB0;opacity:.4;margin:2px 0;width:80%;"></div>
|
||||
<div style="height:1.5px;background:#C4BDB0;opacity:.2;margin:1px 0;width:90%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Blocks -->
|
||||
<div style="flex:1;overflow-y:auto;padding:8px 12px;background:#fff;">
|
||||
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
|
||||
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">1</div> Anrede</div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">Liebe Martha,</div>
|
||||
</div>
|
||||
<div style="border:1px solid var(--turquoise);border-radius:5px;overflow:hidden;margin-bottom:6px;box-shadow:0 0 0 1px var(--turquoise);">
|
||||
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:rgba(0,199,177,.08);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">2</div> Hauptteil</div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">ich schreibe Dir heute...<span class="trans-cursor"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
NON-TRANSCRIBE MODE (standard document view)
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="non-transcribe">
|
||||
<div class="scr-head"><h3>Non-transcribe mode — standard document view</h3><span class="scr-id">S5</span></div>
|
||||
<div class="scr-desc">Outside of transcribe mode, the document detail page uses the <strong>same “Details” drawer pattern</strong>. No bottom panel. The PDF gets the full remaining viewport. Discussion and transcription are accessible via dedicated buttons (Transkribieren enters split mode, Annotieren enters annotation mode). One consistent pattern everywhere — no mode-dependent UI structure.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · non-transcribe · collapsed</div>
|
||||
<div class="desk" style="min-height:400px;">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="topbar">
|
||||
<div class="topbar-main">
|
||||
<div class="back">←</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 2px;"></div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="fa-chip"><div class="av navy">HR</div> <a href="#">Heinrich R.</a></div>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||
<div class="fa-chip"><div class="av purple">MR</div> <a href="#">Martha R.</a></div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="details-toggle">Details <span class="chevron-icon">▼</span></div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="fa-topbar-btn ghost">✎ Transkribieren</div>
|
||||
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||
<div class="fa-topbar-btn ghost" style="padding:3px 5px;">
|
||||
<span style="font-size:8px;">✎</span>
|
||||
</div>
|
||||
<div style="width:14px;height:14px;border-radius:3px;background:var(--sand);display:flex;align-items:center;justify-content:center;font-size:6px;">⇩</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full PDF — no bottom panel -->
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:45%;min-height:240px;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ AGENT TABLE ═══ -->
|
||||
<div class="agent">
|
||||
<h4>Expandable metadata header · Implementation spec</h4>
|
||||
<pre>/* The topbar gains a labeled "Details ▼" toggle button that opens a full-width metadata
|
||||
* drawer below the main topbar row.
|
||||
*
|
||||
* Collapsed (default): topbar looks like today + a "Details ▼" button between
|
||||
* the person chips and the action buttons.
|
||||
* Expanded: a new row slides down with a 3-column grid (desktop):
|
||||
* Col 1: date (long format), location, archive location — icon + value + label
|
||||
* Col 2: sender card + receiver cards — clickable, links to /persons/{id}
|
||||
* conversation icon links to /korrespondenz?senderId=X&receiverId=Y
|
||||
* Col 3: tag chips — clickable, link to /?tag=X
|
||||
*
|
||||
* The drawer PUSHES content down (document flow, not overlay).
|
||||
* Background: color-page (sand) to visually separate from white topbar.
|
||||
* Animation: Svelte slide transition or max-height + overflow:hidden, 200ms ease.
|
||||
*
|
||||
* KEY DECISION: "Details ▼" labeled toggle instead of bare chevron icon.
|
||||
* Reason: 60+ year old users in user interviews — bare icons are easy to miss.
|
||||
* The label makes the interaction self-explanatory and provides a 44×28px min tap target.
|
||||
*
|
||||
* Mobile: single-column stack, person cards full-width with 44px min-height,
|
||||
* conversation links always visible (no hover-reveal on touch). */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Toggle button</td></tr>
|
||||
<tr><td>Label</td><td>"Details" + ▼ chevron</td><td>i18n key: topbar_details_toggle</td></tr>
|
||||
<tr><td>Size</td><td>min 44×28px tap target, text-xs font-semibold</td><td>WCAG 2.5.5 compliant target size</td></tr>
|
||||
<tr><td>Inactive style</td><td>border border-line, text-ink-2, bg-transparent</td><td>Subtle, doesn't compete with action buttons</td></tr>
|
||||
<tr><td>Active style</td><td>bg-primary, text-primary-fg, border-primary</td><td>Clear open state — matches annotate button pattern</td></tr>
|
||||
<tr><td>Chevron</td><td>▼ (U+25BC), rotates 180deg when open</td><td>CSS transition transform 200ms</td></tr>
|
||||
<tr><td>Aria</td><td>aria-expanded, aria-controls="metadata-drawer"</td><td>Button role implicit</td></tr>
|
||||
<tr><td>Keyboard</td><td>Ctrl+M toggles, Escape closes</td><td>Ctrl+M matches "M for metadata"</td></tr>
|
||||
<tr class="grp"><td colspan="3">Drawer (expanded)</td></tr>
|
||||
<tr><td>Layout</td><td>grid 3-col desktop (1fr 1fr 1fr), 1-col mobile</td><td>bg:color-page, border-top:line, p:12px 16px</td></tr>
|
||||
<tr><td>Animation</td><td>Svelte slide transition, 200ms</td><td>Or CSS max-height 0↔auto with overflow:hidden</td></tr>
|
||||
<tr><td>Push behavior</td><td>In document flow, pushes split view down</td><td>Not absolute/overlay — no clipping</td></tr>
|
||||
<tr><td>ID</td><td>id="metadata-drawer"</td><td>role="region", aria-label="Dokumentmetadaten"</td></tr>
|
||||
<tr class="grp"><td colspan="3">Drawer content — Details column</td></tr>
|
||||
<tr><td>Date</td><td>Long format (14. Mai 1943), icon 📅</td><td>Uses existing formatDate utility</td></tr>
|
||||
<tr><td>Location</td><td>Text, icon 📍</td><td>Only shown if doc.creationLocation exists</td></tr>
|
||||
<tr><td>Archive</td><td>Text, icon 📁</td><td>Only shown if doc.archiveLocation exists</td></tr>
|
||||
<tr class="grp"><td colspan="3">Drawer content — Persons column</td></tr>
|
||||
<tr><td>Person card</td><td>border:line, radius:5px, bg:page, hover:accent-bg</td><td>Entire card is a link to /persons/{id}</td></tr>
|
||||
<tr><td>Card content</td><td>18px avatar + full name + alias</td><td>Alias from person.alias field</td></tr>
|
||||
<tr><td>Conversation icon</td><td>💬 appears on hover (desktop), always visible (mobile)</td><td>Links to /korrespondenz?senderId=X&receiverId=Y</td></tr>
|
||||
<tr><td>Mobile card height</td><td>min-height 44px</td><td>WCAG touch target compliance</td></tr>
|
||||
<tr class="grp"><td colspan="3">Drawer content — Tags column</td></tr>
|
||||
<tr><td>Chip</td><td>text-[10px]/600, sand bg, uppercase, radius:3px</td><td>Click → navigate to /?tag=X</td></tr>
|
||||
<tr><td>Hover</td><td>bg-primary, text-primary-fg</td><td>Visual feedback that chips are interactive</td></tr>
|
||||
<tr class="grp"><td colspan="3">Non-transcribe mode</td></tr>
|
||||
<tr><td>Toggle shown?</td><td>Yes — always present in topbar</td><td>Consistent UX across all modes</td></tr>
|
||||
<tr><td>Bottom panel</td><td>Removed entirely — all modes</td><td>Drawer is the single metadata pattern everywhere</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||
<div class="llm">
|
||||
<h2>Implementation Guide — Expandable Metadata Header</h2>
|
||||
|
||||
<h3>1. Scope</h3>
|
||||
<p>Add a labeled “Details” toggle button and a collapsible metadata drawer to <code>DocumentTopBar.svelte</code>. This spec covers <strong>only the header expansion</strong> — the transcription split view, inline comments, and history toolbar are covered in the companion spec (<code>annotation-transcription-final-spec.html</code>).</p>
|
||||
|
||||
<h3>2. State</h3>
|
||||
<ul>
|
||||
<li><code>let metadataOpen = $state(false)</code> in <code>DocumentTopBar.svelte</code>.</li>
|
||||
<li>Toggle on button click. Close on Escape key. Toggle on Ctrl+M.</li>
|
||||
<li>State is local — not persisted. Defaults to closed on every page load.</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. Component Changes</h3>
|
||||
<table>
|
||||
<thead><tr><th>Component</th><th>Change</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>DocumentTopBar.svelte</code></td><td>Add <code>metadataOpen</code> state, toggle button, and conditional drawer div. New props needed: <code>doc.creationLocation</code>, <code>doc.archiveLocation</code>, <code>doc.tags</code>, full sender/receiver objects with aliases.</td></tr>
|
||||
<tr><td><code>MetadataDrawer.svelte</code> (new)</td><td>Extracted child component. Receives the doc object. Renders the 3-column grid (desktop) or 1-column stack (mobile). Contains person cards, tag chips, and metadata fields.</td></tr>
|
||||
<tr><td><code>PersonChipRow.svelte</code></td><td>No change. Still renders the abbreviated chips in the main topbar row.</td></tr>
|
||||
<tr><td><code>DocumentBottomPanel.svelte</code></td><td>Remove entirely. The metadata drawer replaces the Metadata tab. Transcription, Discussion, and History move to inline UI (see companion transcription spec). No bottom panel in any mode.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>4. Toggle Button Placement</h3>
|
||||
<p>In the topbar’s flex row, the button goes <strong>after the person chips divider and before the action buttons divider</strong>:</p>
|
||||
<p><code>← | Title | chips → | <strong>Details ▼</strong> | Transkribieren | Annotieren | Edit | Download</code></p>
|
||||
<p>On mobile (<375px), person chips are hidden. The toggle sits after the title, before the transcribe pill.</p>
|
||||
|
||||
<h3>5. Drawer Markup</h3>
|
||||
<ul>
|
||||
<li>Use Svelte <code>slide</code> transition: <code>{#if metadataOpen}<div transition:slide={{ duration: 200 }}></code></li>
|
||||
<li>The drawer is a direct child of the topbar wrapper, below the main flex row.</li>
|
||||
<li>Desktop: <code>grid grid-cols-3 gap-4 p-3 sm:p-4 bg-canvas border-t border-line</code></li>
|
||||
<li>Mobile: <code>grid grid-cols-1 gap-3 p-3 bg-canvas border-t border-line</code></li>
|
||||
<li>Breakpoint for 3-col: <code>md:grid-cols-3</code> (768px+).</li>
|
||||
</ul>
|
||||
|
||||
<h3>6. Person Cards in Drawer</h3>
|
||||
<ul>
|
||||
<li>Each card: avatar (using <code>personAvatarColor</code>), full name (font-serif), alias (text-xs text-ink-2).</li>
|
||||
<li>Card wraps an <code><a href="/persons/{id}"></code>.</li>
|
||||
<li>Conversation icon: separate <code><a></code> inside the card, absolute-positioned or flex-end. Links to <code>/korrespondenz?senderId={sender.id}&receiverId={receiver.id}</code>.</li>
|
||||
<li>On mobile: <code>min-h-[44px]</code> for touch targets. Conversation icon always visible (<code>opacity-100</code> instead of <code>opacity-0 group-hover:opacity-100</code>).</li>
|
||||
</ul>
|
||||
|
||||
<h3>7. Tag Chips in Drawer</h3>
|
||||
<ul>
|
||||
<li>Each tag: <code><a href="/?tag={tag.name}"></code> with <code>text-[10px] font-semibold uppercase bg-muted rounded px-2 py-0.5 hover:bg-primary hover:text-primary-fg transition-colors</code>.</li>
|
||||
<li><code>aria-label="Dokumente mit Schlagwort {tag.name} filtern"</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h3>8. Accessibility</h3>
|
||||
<ul>
|
||||
<li>Toggle button: <code>aria-expanded={metadataOpen}</code>, <code>aria-controls="metadata-drawer"</code>.</li>
|
||||
<li>Drawer: <code>id="metadata-drawer"</code>, <code>role="region"</code>, <code>aria-label="Dokumentmetadaten"</code>.</li>
|
||||
<li>Person cards: accessible name includes full name + “Zur Personenseite”.</li>
|
||||
<li>Conversation link: <code>aria-label="Korrespondenz zwischen {sender} und {receiver} anzeigen"</code>.</li>
|
||||
<li>Tab order: toggle button → drawer contents (when open) → action buttons.</li>
|
||||
<li>Escape closes drawer and returns focus to the toggle button.</li>
|
||||
</ul>
|
||||
|
||||
<h3>9. i18n Keys</h3>
|
||||
<table>
|
||||
<thead><tr><th>Key</th><th>de</th><th>en</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>topbar_details_toggle</code></td><td>Details</td><td>Details</td></tr>
|
||||
<tr><td><code>topbar_details_date</code></td><td>Datum</td><td>Date</td></tr>
|
||||
<tr><td><code>topbar_details_location</code></td><td>Entstehungsort</td><td>Location</td></tr>
|
||||
<tr><td><code>topbar_details_archive</code></td><td>Archivstandort</td><td>Archive location</td></tr>
|
||||
<tr><td><code>topbar_details_sender</code></td><td>Absender</td><td>Sender</td></tr>
|
||||
<tr><td><code>topbar_details_receivers</code></td><td>Empfänger</td><td>Receivers</td></tr>
|
||||
<tr><td><code>topbar_details_tags</code></td><td>Schlagwörter</td><td>Tags</td></tr>
|
||||
<tr><td><code>topbar_details_conversation</code></td><td>Korrespondenz anzeigen</td><td>View correspondence</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1152
docs/specs/focus-rings-spec.html
Normal file
1152
docs/specs/focus-rings-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
804
docs/specs/transcription-read-mode-final-spec.html
Normal file
804
docs/specs/transcription-read-mode-final-spec.html
Normal file
@@ -0,0 +1,804 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Transcription Read Mode — Final Spec (Clean Split)</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&family=Tinos:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--accent-bg:rgba(161,220,216,.12);--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--font-read:'Tinos',Georgia,serif;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||
|
||||
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||
.pill-g{background:var(--green-tint);color:var(--green-dark);}
|
||||
|
||||
.section{margin-bottom:64px;}
|
||||
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||
|
||||
.scr{margin-bottom:56px;}
|
||||
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||
|
||||
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||
|
||||
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;min-height:520px;}
|
||||
|
||||
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||
|
||||
/* ── FA chrome ── */
|
||||
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;}
|
||||
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
|
||||
/* ── Topbar ── */
|
||||
.fa-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 12px;gap:6px;height:42px;flex-shrink:0;}
|
||||
.fa-topbar .back{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);}
|
||||
.fa-topbar .title{font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.fa-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px 1px 2px;background:var(--sand);border:1px solid #e4e2d7;border-radius:8px;white-space:nowrap;font-size:7px;color:var(--color-text);}
|
||||
.fa-chip .av{width:12px;height:12px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;flex-shrink:0;}
|
||||
.fa-chip .av.navy{background:var(--navy);color:var(--mint);}
|
||||
.fa-chip .av.purple{background:#5A3080;color:#fff;}
|
||||
.fa-topbar-btn{font-size:7px;font-weight:600;padding:3px 8px;border-radius:4px;border:1px solid var(--navy);color:var(--navy);background:transparent;display:flex;align-items:center;gap:3px;cursor:pointer;}
|
||||
.fa-topbar-btn.ghost{border-color:var(--color-border);color:var(--color-text-muted);font-weight:500;}
|
||||
.details-toggle{display:inline-flex;align-items:center;gap:3px;padding:2px 8px 2px 6px;border-radius:4px;font-size:7px;font-weight:600;color:var(--color-text-muted);cursor:pointer;border:1px solid var(--color-border);background:transparent;white-space:nowrap;}
|
||||
|
||||
/* ── Mode switcher ── */
|
||||
.mode-sw{display:inline-flex;border:1px solid var(--color-border);border-radius:4px;overflow:hidden;font-size:6px;font-weight:600;}
|
||||
.mode-sw span{padding:3px 8px;cursor:pointer;color:var(--color-text-muted);}
|
||||
.mode-sw span.active{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||
.mode-sw span:not(.active):hover{background:var(--sand);}
|
||||
|
||||
/* ── PDF area ── */
|
||||
.pdf-area{background:#D4D0C8;flex:1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;}
|
||||
.paper{background:#FFFEF8;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:9px 11px;display:flex;flex-direction:column;gap:2px;position:relative;}
|
||||
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px;}
|
||||
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px;}
|
||||
|
||||
/* ── Annotation rects (dimmed in read mode) ── */
|
||||
.ann-rect{position:absolute;border-radius:2px;}
|
||||
.ann-rect.trans{border:1.5px solid var(--turquoise);background:rgba(0,199,177,.1);}
|
||||
.ann-rect.trans.dimmed{border-color:rgba(0,199,177,.3);background:rgba(0,199,177,.04);}
|
||||
|
||||
/* ── Split ── */
|
||||
.split{display:flex;flex:1;overflow:hidden;}
|
||||
.split-handle{width:4px;background:var(--color-border);cursor:col-resize;flex-shrink:0;display:flex;align-items:center;justify-content:center;}
|
||||
.split-handle::after{content:'';width:2px;height:20px;background:var(--color-text-muted);border-radius:1px;opacity:.3;}
|
||||
|
||||
/* ── Read-mode text ── */
|
||||
.read-text{font-family:var(--font-read);font-size:10px;line-height:1.85;color:var(--color-text);padding:16px 20px;}
|
||||
.read-text .para{margin-bottom:10px;cursor:pointer;padding:2px 4px;border-radius:3px;transition:background .15s ease;}
|
||||
.read-text .para:hover{background:rgba(0,199,177,.06);}
|
||||
.read-text .para.highlighted{background:rgba(0,199,177,.1);transition:background .3s ease;}
|
||||
.read-text .greeting{font-style:italic;}
|
||||
.read-text .closing{margin-top:12px;text-align:right;font-style:italic;}
|
||||
.read-text .illegible{color:var(--color-text-muted);font-style:italic;font-size:9px;}
|
||||
|
||||
/* ── Status bar ── */
|
||||
.status-bar{background:var(--sand);border-top:1px solid #e4e2d7;height:18px;display:flex;align-items:center;padding:0 8px;font-size:7px;color:var(--color-text-muted);gap:8px;flex-shrink:0;}
|
||||
|
||||
/* ── Scroll sync highlight on PDF ── */
|
||||
.ann-rect.highlight-flash{border-color:var(--turquoise) !important;background:rgba(0,199,177,.18) !important;transition:all .3s ease;animation:flash-fade 1.5s ease-out forwards;}
|
||||
@keyframes flash-fade{0%{background:rgba(0,199,177,.18);border-color:var(--turquoise);}100%{background:rgba(0,199,177,.04);border-color:rgba(0,199,177,.3);}}
|
||||
|
||||
/* ── Agent table ── */
|
||||
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
|
||||
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||
|
||||
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
|
||||
.llm h4{font-size:12px;font-weight:600;margin:16px 0 6px;color:var(--color-text);}
|
||||
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||
.llm li{margin-bottom:4px;}
|
||||
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||
.llm pre{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:12px 16px;border-radius:var(--radius-md);overflow-x:auto;margin:8px 0 12px;line-height:1.6;}
|
||||
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.llm td{color:var(--color-text-muted);}
|
||||
|
||||
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<div class="doc-header">
|
||||
<div>
|
||||
<h1>Transcription Read Mode — Final Spec</h1>
|
||||
<p>A focused reading experience for completed transcriptions. Uses the <strong>clean split</strong> layout: PDF scan on the left, flowing prose on the right. All editing chrome is stripped — no block borders, no comment threads, no toolbars. The text reads like a letter, not like an editing interface.</p>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
Familienarchiv<br/>
|
||||
<span class="pill pill-g">Final</span><br/>
|
||||
2026-04-05 · @leonievoss
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
SECTION: DESIGN RATIONALE
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Design rationale</div>
|
||||
<p class="prose">Transcribe mode is for editing. But most visits to a completed transcription are for <strong>reading</strong> — comparing the handwriting with the typed text, sharing with family, or just revisiting a letter. Read mode strips away all editing chrome and presents the transcription as flowing prose alongside the original scan.</p>
|
||||
<p class="prose">The <strong>clean split</strong> was chosen over the full-page reader (PDF hidden) and the interleaved view (cropped PDF per block) because it preserves the familiar side-by-side layout from transcribe mode while dramatically reducing visual noise. Users can switch between reading and editing without re-learning the spatial layout.</p>
|
||||
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;margin-top:20px;">
|
||||
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:180px;">
|
||||
<div style="font-weight:600;color:var(--green);margin-bottom:4px;">Kept</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
|
||||
<li>Transcription text (flowing prose)</li>
|
||||
<li>PDF scan viewer (same position)</li>
|
||||
<li>Topbar (title, Details toggle, person chips)</li>
|
||||
<li>Mode switcher (Lesen / Bearbeiten)</li>
|
||||
<li>Resizable split handle</li>
|
||||
<li>Scroll sync (click paragraph ↔ PDF)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:180px;">
|
||||
<div style="font-weight:600;color:var(--color-error);margin-bottom:4px;">Removed</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
|
||||
<li>Block borders & numbered badges</li>
|
||||
<li>Contenteditable / cursor</li>
|
||||
<li>Comment threads & “Kommentieren” buttons</li>
|
||||
<li>Presence dots & user indicators</li>
|
||||
<li>Hint strip (“Markiere eine Passage…”)</li>
|
||||
<li>Drag handles, sort controls</li>
|
||||
<li>Auto-save status indicator</li>
|
||||
<li>Add-block CTA</li>
|
||||
<li>History / “Verlauf” button</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
S1: DESKTOP — READ MODE
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="s1">
|
||||
<div class="scr-head"><h3>S1 — Desktop read mode</h3><span class="scr-id">S1</span></div>
|
||||
<div class="scr-desc">Side-by-side split: PDF scan on the left with dimmed annotation outlines, flowing serif prose on the right. The mode switcher shows “Lesen” as active. Clicking a paragraph briefly highlights the matching PDF region (turquoise flash, 1.5s fade).</div>
|
||||
<div class="scr-var"><strong>Primary reading state</strong> — the default view when a transcription exists.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px</div>
|
||||
<div class="desk">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="fa-topbar">
|
||||
<div class="back">←</div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="fa-chip"><div class="av navy">HR</div> Heinrich R.</div>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||
<div class="fa-chip"><div class="av purple">MR</div> Martha R.</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="details-toggle">Details ▼</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<!-- Mode switcher: Lesen active -->
|
||||
<div class="mode-sw"><span class="active">Lesen</span><span>Bearbeiten</span></div>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:430px;">
|
||||
<!-- PDF scan — annotations dimmed -->
|
||||
<div style="flex:1;display:flex;flex-direction:column;">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:260px;position:relative;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
|
||||
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
|
||||
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||
|
||||
<!-- Dimmed annotation outlines — no numbered badges -->
|
||||
<div class="ann-rect trans dimmed" style="left:2%;top:0%;width:50%;height:10%;"></div>
|
||||
<div class="ann-rect trans dimmed" style="left:2%;top:14%;width:96%;height:32%;"></div>
|
||||
<div class="ann-rect trans dimmed" style="left:2%;top:50%;width:96%;height:22%;"></div>
|
||||
<div class="ann-rect trans dimmed" style="left:20%;top:80%;width:60%;height:12%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<!-- Right panel: flowing prose, no block chrome -->
|
||||
<div style="width:400px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;background:#fff;">
|
||||
<div class="read-text" style="flex:1;overflow-y:auto;">
|
||||
<div class="para greeting">Liebe Martha,</div>
|
||||
<div class="para">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umständen entsprechend gut. Der Arzt sagt <span class="illegible">[unleserlich]</span> Wochen noch dauern wird.</div>
|
||||
<div class="para">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen. Und Lotte soll weiter so fleißig in der Schule sein.</div>
|
||||
<div class="para closing">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<span>4 Abschnitte</span>
|
||||
<span style="margin-left:auto;">Zuletzt bearbeitet: Oma Inge, 14:23</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>S1 · Desktop read mode</h4>
|
||||
<pre>/* Same side-by-side layout as transcribe mode, but the right panel renders
|
||||
* the transcription as continuous flowing prose instead of block cards.
|
||||
*
|
||||
* Key differences from transcribe mode:
|
||||
* - No block borders, headers, footers, or numbered badges
|
||||
* - No contenteditable — text is plain rendered HTML
|
||||
* - No comment threads, no "Kommentieren" buttons
|
||||
* - No presence dots, no hint strip, no auto-save indicator
|
||||
* - Annotation rects on PDF are dimmed (opacity ~0.3, no badges)
|
||||
* - Still clickable for scroll-sync
|
||||
* - Status bar shows: "4 Abschnitte · Zuletzt bearbeitet: Oma Inge, 14:23"
|
||||
*
|
||||
* Scroll sync:
|
||||
* - Click paragraph → matching PDF annotation flashes turquoise (1.5s fade)
|
||||
* - Click PDF annotation → matching paragraph gets subtle bg highlight (1.5s fade)
|
||||
* - PDF auto-scrolls to center the annotation in the viewport */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Text panel</td></tr>
|
||||
<tr><td>Font</td><td>Tinos (serif), 16px, line-height 1.85</td><td>Generous reading typography</td></tr>
|
||||
<tr><td>Padding</td><td>24px 32px</td><td>Comfortable margins like a book page</td></tr>
|
||||
<tr><td>Paragraphs</td><td>One <p> per transcription block</td><td>mb-4 between paragraphs</td></tr>
|
||||
<tr><td>[unleserlich]</td><td>italic, text-ink-2, font-size: 0.9em</td><td>Subtle but readable</td></tr>
|
||||
<tr><td>Hover</td><td>Subtle turquoise bg at 6% opacity</td><td>Hint that paragraphs are clickable</td></tr>
|
||||
<tr class="grp"><td colspan="3">PDF panel</td></tr>
|
||||
<tr><td>Annotations</td><td>Dimmed: border-opacity 0.3, bg-opacity 0.04</td><td>Still clickable for scroll-sync</td></tr>
|
||||
<tr><td>Badges</td><td>Hidden</td><td>No numbered circles in read mode</td></tr>
|
||||
<tr><td>Scroll sync</td><td>Click para → PDF scrolls, flash 1.5s</td><td>Turquoise tint at 18% → fade to 4%</td></tr>
|
||||
<tr class="grp"><td colspan="3">Status bar</td></tr>
|
||||
<tr><td>Content</td><td>"N Abschnitte · Zuletzt bearbeitet: Name, HH:mm"</td><td>Uses most recent updated_at across blocks</td></tr>
|
||||
<tr><td>Height</td><td>28px, sand background</td><td>Same as transcribe mode status bar</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
S2: DESKTOP — SCROLL SYNC INTERACTION
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="s2">
|
||||
<div class="scr-head"><h3>S2 — Scroll sync highlight</h3><span class="scr-id">S2</span></div>
|
||||
<div class="scr-desc">The user clicked the second paragraph. The matching PDF annotation flashes with a turquoise highlight that fades over 1.5 seconds. The paragraph itself gets a subtle background tint. This is the <strong>only interactive element</strong> in read mode — no editing, no comments.</div>
|
||||
<div class="scr-var"><strong>Click-to-highlight interaction</strong> — bidirectional scroll sync between text and scan.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px</div>
|
||||
<div class="desk">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="fa-topbar">
|
||||
<div class="back">←</div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="fa-chip"><div class="av navy">HR</div> Heinrich R.</div>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||
<div class="fa-chip"><div class="av purple">MR</div> Martha R.</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="details-toggle">Details ▼</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="mode-sw"><span class="active">Lesen</span><span>Bearbeiten</span></div>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:430px;">
|
||||
<div style="flex:1;display:flex;flex-direction:column;">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:260px;position:relative;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
|
||||
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
|
||||
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||
|
||||
<div class="ann-rect trans dimmed" style="left:2%;top:0%;width:50%;height:10%;"></div>
|
||||
<!-- THIS annotation is highlighted — user clicked paragraph 2 -->
|
||||
<div class="ann-rect trans highlight-flash" style="left:2%;top:14%;width:96%;height:32%;"></div>
|
||||
<div class="ann-rect trans dimmed" style="left:2%;top:50%;width:96%;height:22%;"></div>
|
||||
<div class="ann-rect trans dimmed" style="left:20%;top:80%;width:60%;height:12%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<div style="width:400px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;background:#fff;">
|
||||
<div class="read-text" style="flex:1;overflow-y:auto;">
|
||||
<div class="para greeting">Liebe Martha,</div>
|
||||
<!-- THIS paragraph is highlighted -->
|
||||
<div class="para highlighted">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umständen entsprechend gut. Der Arzt sagt <span class="illegible">[unleserlich]</span> Wochen noch dauern wird.</div>
|
||||
<div class="para">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen. Und Lotte soll weiter so fleißig in der Schule sein.</div>
|
||||
<div class="para closing">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<span>4 Abschnitte</span>
|
||||
<span style="margin-left:auto;">Zuletzt bearbeitet: Oma Inge, 14:23</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>S2 · Scroll sync highlight</h4>
|
||||
<pre>/* Bidirectional scroll sync with visual feedback.
|
||||
*
|
||||
* Text → PDF:
|
||||
* 1. User clicks a paragraph
|
||||
* 2. Paragraph gets .highlighted class (turquoise bg at 10%)
|
||||
* 3. Matching annotation rect gets .highlight-flash class
|
||||
* 4. PDF viewport scrolls to center the annotation
|
||||
* 5. Both highlights fade over 1.5s via CSS animation
|
||||
*
|
||||
* PDF → Text:
|
||||
* 1. User clicks a dimmed annotation rect
|
||||
* 2. Annotation flashes (same .highlight-flash)
|
||||
* 3. Matching paragraph gets .highlighted
|
||||
* 4. Text panel scrolls to center the paragraph
|
||||
* 5. Both fade over 1.5s
|
||||
*
|
||||
* Implementation: each paragraph has data-block-id matching the
|
||||
* transcription block's annotation_id. The annotation rects already
|
||||
* have annotation IDs from transcribe mode. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Highlight animation</td></tr>
|
||||
<tr><td>Paragraph bg</td><td>rgba(0,199,177,.10)</td><td>Turquoise at 10%, fades to 0</td></tr>
|
||||
<tr><td>Annotation flash</td><td>rgba(0,199,177,.18) → .04</td><td>Border returns to .3 opacity</td></tr>
|
||||
<tr><td>Duration</td><td>1.5s ease-out</td><td>CSS animation, no JS timers needed</td></tr>
|
||||
<tr><td>Scroll behavior</td><td>smooth, block: center</td><td>scrollIntoView({ behavior: 'smooth', block: 'center' })</td></tr>
|
||||
<tr class="grp"><td colspan="3">Data binding</td></tr>
|
||||
<tr><td>Paragraph attr</td><td>data-block-id="{annotation_id}"</td><td>Links text to PDF annotation</td></tr>
|
||||
<tr><td>Annotation attr</td><td>data-annotation-id="{id}"</td><td>Already exists from transcribe mode</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
S3: DESKTOP — NO TRANSCRIPTION YET
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="s3">
|
||||
<div class="scr-head"><h3>S3 — No transcription (empty state)</h3><span class="scr-id">S3</span></div>
|
||||
<div class="scr-desc">When no transcription blocks exist, the mode switcher defaults to “Bearbeiten” and the right panel shows an empty state encouraging the user to start transcribing. The “Lesen” tab is disabled (greyed out).</div>
|
||||
<div class="scr-var"><strong>Empty state</strong> — no read mode available until at least one block exists.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px</div>
|
||||
<div class="desk" style="min-height:400px;">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="fa-topbar">
|
||||
<div class="back">←</div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="details-toggle">Details ▼</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<!-- Mode switcher: Bearbeiten active, Lesen disabled -->
|
||||
<div class="mode-sw"><span style="opacity:.35;cursor:not-allowed;">Lesen</span><span class="active">Bearbeiten</span></div>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:320px;">
|
||||
<div style="flex:1;display:flex;flex-direction:column;">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:200px;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div>
|
||||
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<div style="width:400px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;background:#fff;align-items:center;justify-content:center;text-align:center;padding:32px;">
|
||||
<div style="width:48px;height:48px;border-radius:50%;background:var(--sand);display:flex;align-items:center;justify-content:center;margin-bottom:12px;">
|
||||
<span style="font-size:20px;opacity:.5;">✎</span>
|
||||
</div>
|
||||
<div style="font-family:var(--font-sans);font-size:11px;font-weight:600;color:var(--color-text);margin-bottom:4px;">Noch keine Transkription</div>
|
||||
<div style="font-family:var(--font-sans);font-size:10px;color:var(--color-text-muted);max-width:200px;line-height:1.6;">Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>S3 · Empty state</h4>
|
||||
<pre>/* When transcription_blocks count is 0:
|
||||
* - Mode switcher defaults to "Bearbeiten"
|
||||
* - "Lesen" tab is disabled: opacity 0.35, cursor: not-allowed, not clickable
|
||||
* - Right panel shows empty state with pencil icon, title, and description
|
||||
* - No status bar (nothing to show)
|
||||
*
|
||||
* As soon as the first block is saved, "Lesen" becomes enabled.
|
||||
* The mode does NOT auto-switch — user stays in Bearbeiten. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Empty state</td></tr>
|
||||
<tr><td>Icon</td><td>Pencil in 48px sand circle</td><td>Centered vertically in panel</td></tr>
|
||||
<tr><td>Title</td><td>"Noch keine Transkription"</td><td>i18n key: transcription_empty_title</td></tr>
|
||||
<tr><td>Description</td><td>"Zeichne Bereiche auf dem Scan…"</td><td>i18n key: transcription_empty_desc</td></tr>
|
||||
<tr class="grp"><td colspan="3">Mode switcher</td></tr>
|
||||
<tr><td>Lesen tab</td><td>Disabled: opacity .35, not-allowed</td><td>Enabled when block count > 0</td></tr>
|
||||
<tr><td>Default</td><td>"Bearbeiten" active</td><td>Enters transcribe mode directly</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
S4: MOBILE — READ MODE
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="s4">
|
||||
<div class="scr-head"><h3>S4 — Mobile read mode</h3><span class="scr-id">S4</span></div>
|
||||
<div class="scr-desc">On mobile, the split view becomes vertical: a collapsible PDF strip (70px) at the top, flowing text below. The mode switcher abbreviates “Bearbeiten” to “Bearb.” to fit. Tapping the PDF strip expands it; tapping again collapses. Paragraphs are still tappable for scroll-sync.</div>
|
||||
<div class="scr-var"><strong>Mobile layout</strong> — stacked vertical with collapsible scan strip.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Mobile · 320px</div>
|
||||
<div class="phone" style="height:620px;">
|
||||
<div class="pst"><b>14:23</b><span>••• WiFi 🔋</span></div>
|
||||
<div class="pb">
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:6px 12px;display:flex;align-items:center;gap:6px;">
|
||||
<span style="font-size:11px;color:var(--color-text-muted);">←</span>
|
||||
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
|
||||
<div class="mode-sw"><span class="active" style="font-size:7px;">Lesen</span><span style="font-size:7px;">Bearb.</span></div>
|
||||
</div>
|
||||
<!-- Collapsible PDF strip -->
|
||||
<div style="background:#D4D0C8;height:70px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid #C4C0B8;position:relative;">
|
||||
<div style="background:#FFFEF8;width:42%;padding:5px 7px;box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:1px;">
|
||||
<div style="font-size:5px;color:#8A8070;font-style:italic;opacity:.7;">Liebe Martha,</div>
|
||||
<div style="height:2px;background:#C4BDB0;opacity:.4;margin:2px 0;width:80%;"></div>
|
||||
<div style="height:1.5px;background:#C4BDB0;opacity:.2;margin:1px 0;width:90%;"></div>
|
||||
</div>
|
||||
<!-- Expand hint -->
|
||||
<div style="position:absolute;bottom:3px;right:8px;font-size:6px;color:var(--color-text-muted);opacity:.6;">▲ Scan vergrößern</div>
|
||||
</div>
|
||||
<!-- Flowing text -->
|
||||
<div style="flex:1;overflow-y:auto;padding:16px 16px;background:#fff;">
|
||||
<div style="font-family:'Tinos',Georgia,serif;font-size:13px;line-height:1.9;color:var(--color-text);">
|
||||
<p style="margin-bottom:10px;font-style:italic;">Liebe Martha,</p>
|
||||
<p style="margin-bottom:10px;">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umständen entsprechend gut. Der Arzt sagt <em style="color:var(--color-text-muted);">[unleserlich]</em> Wochen noch dauern wird.</p>
|
||||
<p style="margin-bottom:10px;">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen. Und Lotte soll weiter so fleißig in der Schule sein.</p>
|
||||
<p style="text-align:right;font-style:italic;">In ewiger Liebe,<br/>Dein Heinrich</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Status bar -->
|
||||
<div style="background:var(--sand);border-top:1px solid #e4e2d7;padding:4px 12px;font-size:8px;color:var(--color-text-muted);display:flex;justify-content:space-between;">
|
||||
<span>4 Abschnitte</span>
|
||||
<span>Oma Inge, 14:23</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: expanded PDF strip -->
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Mobile · 320px · scan expanded</div>
|
||||
<div class="phone" style="height:620px;">
|
||||
<div class="pst"><b>14:23</b><span>••• WiFi 🔋</span></div>
|
||||
<div class="pb">
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:6px 12px;display:flex;align-items:center;gap:6px;">
|
||||
<span style="font-size:11px;color:var(--color-text-muted);">←</span>
|
||||
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
|
||||
<div class="mode-sw"><span class="active" style="font-size:7px;">Lesen</span><span style="font-size:7px;">Bearb.</span></div>
|
||||
</div>
|
||||
<!-- Expanded PDF strip -->
|
||||
<div style="background:#D4D0C8;height:200px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid #C4C0B8;position:relative;">
|
||||
<div style="background:#FFFEF8;width:50%;padding:8px 10px;box-shadow:0 2px 6px rgba(0,0,0,.12);border-radius:1px;position:relative;">
|
||||
<div style="font-size:6px;color:#8A8070;font-style:italic;opacity:.7;margin-bottom:3px;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;height:2px;"></div><div class="ps" style="width:85%;height:1.5px;"></div><div class="ps" style="width:92%;height:1.5px;"></div>
|
||||
<div class="pl" style="width:78%;height:2px;"></div><div class="ps" style="width:88%;height:1.5px;"></div>
|
||||
<div class="pl" style="width:84%;height:2px;"></div><div class="ps" style="width:70%;height:1.5px;"></div>
|
||||
<div style="font-size:5px;color:#8A8070;margin-top:4px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||
<!-- Dimmed annotations visible when expanded -->
|
||||
<div class="ann-rect trans dimmed" style="left:3%;top:0%;width:45%;height:12%;"></div>
|
||||
<div class="ann-rect trans dimmed" style="left:3%;top:16%;width:94%;height:35%;"></div>
|
||||
</div>
|
||||
<!-- Collapse hint -->
|
||||
<div style="position:absolute;bottom:3px;right:8px;font-size:6px;color:var(--color-text-muted);opacity:.6;">▼ Scan verkleinern</div>
|
||||
</div>
|
||||
<!-- Text below (shorter) -->
|
||||
<div style="flex:1;overflow-y:auto;padding:14px 16px;background:#fff;">
|
||||
<div style="font-family:'Tinos',Georgia,serif;font-size:13px;line-height:1.9;color:var(--color-text);">
|
||||
<p style="margin-bottom:10px;font-style:italic;">Liebe Martha,</p>
|
||||
<p style="margin-bottom:10px;">ich schreibe Dir heute aus dem Lazarett in Breslau…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>S4 · Mobile read mode</h4>
|
||||
<pre>/* On viewports < 768px, the side-by-side split becomes vertical:
|
||||
* - PDF scan strip at top (collapsed: 70px, expanded: ~50vh)
|
||||
* - Flowing text below, full-width
|
||||
* - Tap PDF strip to toggle expand/collapse
|
||||
* - Expand hint text: "▲ Scan vergrößern" / "▼ Scan verkleinern"
|
||||
*
|
||||
* Mode switcher abbreviates: "Lesen | Bearb."
|
||||
* Scroll-sync: tapping a paragraph briefly highlights the matching
|
||||
* region in the expanded PDF. If PDF is collapsed, it auto-expands
|
||||
* first, then scrolls to the annotation.
|
||||
*
|
||||
* Same status bar at the bottom, same flowing prose styling. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">PDF strip</td></tr>
|
||||
<tr><td>Collapsed height</td><td>70px</td><td>Shows miniature scan preview</td></tr>
|
||||
<tr><td>Expanded height</td><td>~50vh or max 300px</td><td>Smooth CSS transition (300ms ease)</td></tr>
|
||||
<tr><td>Toggle</td><td>Tap anywhere on strip</td><td>Hint text in bottom-right corner</td></tr>
|
||||
<tr class="grp"><td colspan="3">Text area</td></tr>
|
||||
<tr><td>Font</td><td>Tinos, 15px, line-height 1.9</td><td>Slightly larger than desktop for touch</td></tr>
|
||||
<tr><td>Padding</td><td>16px</td><td>Full-width, no wasted space</td></tr>
|
||||
<tr class="grp"><td colspan="3">Mode switcher</td></tr>
|
||||
<tr><td>Labels</td><td>"Lesen | Bearb."</td><td>Abbreviated to fit mobile topbar</td></tr>
|
||||
<tr><td>Font size</td><td>10px</td><td>Compact but readable</td></tr>
|
||||
<tr class="grp"><td colspan="3">Scroll sync on mobile</td></tr>
|
||||
<tr><td>Tap paragraph</td><td>Expand PDF if collapsed, then highlight</td><td>Auto-expand + scroll + flash</td></tr>
|
||||
<tr><td>Tap annotation</td><td>Collapse PDF, scroll text to paragraph</td><td>Smart collapse after showing match</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
S5: MODE SWITCHER DETAIL
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="s5">
|
||||
<div class="scr-head"><h3>S5 — Mode switcher states</h3><span class="scr-id">S5</span></div>
|
||||
<div class="scr-desc">The mode switcher is a segmented control in the topbar that replaces the previous “Transkribieren” turquoise button. It governs three visual states: <strong>Lesen</strong> (read mode, this spec), <strong>Bearbeiten</strong> (transcribe/edit mode), and the existing <strong>Annotieren</strong> button (yellow comment annotations). The modes are mutually exclusive.</div>
|
||||
<div class="scr-var"><strong>Segmented control</strong> — replacing the turquoise “Transkribieren” button.</div>
|
||||
|
||||
<div class="previews" style="gap:16px;">
|
||||
<!-- State 1: Lesen active -->
|
||||
<div class="prev-col" style="align-items:center;">
|
||||
<div class="bp-lbl">Lesen active</div>
|
||||
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
|
||||
<div class="mode-sw" style="font-size:8px;"><span class="active" style="padding:4px 12px;">Lesen</span><span style="padding:4px 12px;">Bearbeiten</span></div>
|
||||
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
|
||||
<div class="fa-topbar-btn ghost" style="font-size:8px;padding:4px 10px;">✎ Annotieren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State 2: Bearbeiten active -->
|
||||
<div class="prev-col" style="align-items:center;">
|
||||
<div class="bp-lbl">Bearbeiten active</div>
|
||||
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
|
||||
<div class="mode-sw" style="font-size:8px;"><span style="padding:4px 12px;">Lesen</span><span class="active" style="padding:4px 12px;">Bearbeiten</span></div>
|
||||
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
|
||||
<div class="fa-topbar-btn ghost" style="font-size:8px;padding:4px 10px;">✎ Annotieren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State 3: Annotieren active -->
|
||||
<div class="prev-col" style="align-items:center;">
|
||||
<div class="bp-lbl">Annotieren active (separate button)</div>
|
||||
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
|
||||
<div class="mode-sw" style="font-size:8px;"><span style="padding:4px 12px;opacity:.5;">Lesen</span><span style="padding:4px 12px;opacity:.5;">Bearbeiten</span></div>
|
||||
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
|
||||
<div class="fa-topbar-btn" style="font-size:8px;padding:4px 10px;background:var(--navy);color:#fff;border-color:var(--navy);">✎ Annotieren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State 4: Lesen disabled (no blocks) -->
|
||||
<div class="prev-col" style="align-items:center;">
|
||||
<div class="bp-lbl">No transcription blocks</div>
|
||||
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
|
||||
<div class="mode-sw" style="font-size:8px;"><span style="padding:4px 12px;opacity:.35;cursor:not-allowed;">Lesen</span><span class="active" style="padding:4px 12px;">Bearbeiten</span></div>
|
||||
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
|
||||
<div class="fa-topbar-btn ghost" style="font-size:8px;padding:4px 10px;">✎ Annotieren</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>S5 · Mode switcher states</h4>
|
||||
<pre>/* Three mutually exclusive modes:
|
||||
*
|
||||
* 1. Lesen (read) — this spec. Flowing prose, dimmed annotations, no editing.
|
||||
* 2. Bearbeiten (edit) — annotation-transcription-final-spec. Block cards, contenteditable.
|
||||
* 3. Annotieren — yellow comment annotations on PDF. Separate button, not in segmented control.
|
||||
*
|
||||
* The segmented control only contains Lesen + Bearbeiten.
|
||||
* Annotieren is a separate button that, when active, deselects both Lesen and Bearbeiten
|
||||
* (both appear deselected/dimmed in the segmented control).
|
||||
*
|
||||
* When the user clicks Annotieren while in read/transcribe mode:
|
||||
* → Enter annotate mode, both segmented items dim
|
||||
* When the user clicks a segmented item while in annotate mode:
|
||||
* → Exit annotate mode, enter the selected mode
|
||||
*
|
||||
* State: let mode: 'read' | 'transcribe' | 'annotate' = $state(...)
|
||||
* Default: 'read' if blocks.length > 0, else 'transcribe'
|
||||
* The "Annotieren" button is hidden if !canAnnotate || !isPdf */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Segmented control</td></tr>
|
||||
<tr><td>Items</td><td>"Lesen" | "Bearbeiten"</td><td>Mobile: "Lesen" | "Bearb."</td></tr>
|
||||
<tr><td>Active style</td><td>bg:navy, color:#fff</td><td>Rounded within the pill</td></tr>
|
||||
<tr><td>Inactive style</td><td>bg:transparent, color:muted</td><td>Hover: bg:sand</td></tr>
|
||||
<tr><td>Dimmed style</td><td>Both items at opacity .5</td><td>Only when annotate mode is active</td></tr>
|
||||
<tr><td>Disabled (Lesen)</td><td>opacity .35, cursor not-allowed</td><td>When no transcription blocks exist</td></tr>
|
||||
<tr class="grp"><td colspan="3">Annotieren button</td></tr>
|
||||
<tr><td>Default</td><td>Ghost style (border:muted)</td><td>Same as current topbar button</td></tr>
|
||||
<tr><td>Active</td><td>bg:navy, color:#fff</td><td>Filled state when annotate mode on</td></tr>
|
||||
<tr><td>Visibility</td><td>canAnnotate && isPdf</td><td>Hidden for non-PDF documents</td></tr>
|
||||
<tr class="grp"><td colspan="3">Accessibility</td></tr>
|
||||
<tr><td>Segmented</td><td>role="tablist", children role="tab"</td><td>aria-selected on active tab</td></tr>
|
||||
<tr><td>Annotieren</td><td>aria-pressed={annotateMode}</td><td>Toggle button semantics</td></tr>
|
||||
<tr><td>Disabled tab</td><td>aria-disabled="true", tabindex="-1"</td><td>Not focusable when no blocks</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||
<div class="llm">
|
||||
<h2>Implementation Guide — Transcription Read Mode (Clean Split)</h2>
|
||||
|
||||
<h3>1. Overview</h3>
|
||||
<p>Read mode is the default view for documents that have transcription blocks. It reuses the same side-by-side split layout as transcribe mode but replaces the editable block cards with flowing serif prose. The goal is a distraction-free reading experience that still lets users compare handwriting with typed text.</p>
|
||||
|
||||
<h3>2. Mode State Management</h3>
|
||||
<p>The document detail page manages a single <code>mode</code> state that governs the entire view:</p>
|
||||
<pre>let mode: 'read' | 'transcribe' | 'annotate' = $state(
|
||||
blocks.length > 0 ? 'read' : 'transcribe'
|
||||
);</pre>
|
||||
<ul>
|
||||
<li><code>mode === 'read'</code> → this spec (flowing prose, dimmed annotations, no editing)</li>
|
||||
<li><code>mode === 'transcribe'</code> → annotation-transcription-final-spec (block cards, contenteditable)</li>
|
||||
<li><code>mode === 'annotate'</code> → yellow comment annotations on PDF</li>
|
||||
<li>The segmented control in the topbar toggles between <code>'read'</code> and <code>'transcribe'</code>.</li>
|
||||
<li>The “Annotieren” button toggles <code>'annotate'</code> on/off. When entering annotate mode, the previous mode (read or transcribe) is stored so the user returns to it when exiting.</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. Component Architecture</h3>
|
||||
<h4>3a. New components</h4>
|
||||
<table>
|
||||
<thead><tr><th>Component</th><th>Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>TranscriptionReadView.svelte</code></td><td>Right panel content in read mode. Renders transcription blocks as flowing prose (<code><article></code> with <code><p></code> per block). Handles scroll-sync click handlers.</td></tr>
|
||||
<tr><td><code>ModeSwitcher.svelte</code></td><td>Segmented control (<code>Lesen | Bearbeiten</code>). Props: <code>mode</code> (bindable), <code>hasBlocks</code> (disables Lesen when false). Emits mode changes.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>3b. Modified components</h4>
|
||||
<table>
|
||||
<thead><tr><th>Component</th><th>Change</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>DocumentTopBar.svelte</code></td><td>Replace the <code>Transkribieren</code> button with <code>ModeSwitcher</code>. Keep the <code>Annotieren</code> button separate. Add <code>mode</code> bindable prop.</td></tr>
|
||||
<tr><td><code>[id]/+page.svelte</code></td><td>Add <code>mode</code> state. Conditionally render <code>TranscriptionReadView</code> vs <code>TranscriptionEditView</code> in the right panel based on <code>mode</code>.</td></tr>
|
||||
<tr><td><code>PdfAnnotationLayer.svelte</code></td><td>Accept <code>dimmed</code> prop. When true: annotation rects get opacity 0.3, no numbered badges, but remain clickable for scroll-sync.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>4. Read View Rendering</h3>
|
||||
<h4>4a. Text rendering</h4>
|
||||
<ul>
|
||||
<li>Fetch transcription blocks from <code>GET /api/documents/{id}/transcription-blocks</code> (same endpoint as transcribe mode).</li>
|
||||
<li>Render each block as a <code><p data-block-id="{block.annotation_id}"></code> inside an <code><article></code> element.</li>
|
||||
<li>Typography: <code>font-family: Tinos, Georgia, serif; font-size: 16px; line-height: 1.85</code>.</li>
|
||||
<li>Padding: <code>24px 32px</code> for comfortable reading margins.</li>
|
||||
<li><code>[unleserlich]</code> markers: detect via regex <code>/\[unleserlich\]/g</code> and wrap in <code><em class="text-ink-2 italic text-[0.9em]"></code>.</li>
|
||||
<li>Text is <strong>not</strong> contenteditable. No cursor, no selection highlights, no editing.</li>
|
||||
</ul>
|
||||
|
||||
<h4>4b. Scroll sync</h4>
|
||||
<ul>
|
||||
<li>Each paragraph has a click handler that dispatches a <code>highlight-annotation</code> event with the <code>annotation_id</code>.</li>
|
||||
<li>The PDF viewer listens for this event, scrolls to the annotation, and applies a CSS animation (<code>flash-fade</code>, 1.5s ease-out).</li>
|
||||
<li>Reverse direction: clicking a dimmed annotation on the PDF dispatches <code>highlight-paragraph</code> with the <code>annotation_id</code>. The text panel scrolls the matching paragraph into view and applies a background highlight that fades.</li>
|
||||
<li>Use <code>scrollIntoView({ behavior: 'smooth', block: 'center' })</code> for both directions.</li>
|
||||
<li>The highlight CSS animation: <code>background rgba(0,199,177,.10) → transparent</code> over 1.5s.</li>
|
||||
</ul>
|
||||
|
||||
<h3>5. PDF Annotations in Read Mode</h3>
|
||||
<ul>
|
||||
<li>Turquoise annotation rectangles are rendered but <strong>dimmed</strong>: border opacity 0.3, background opacity 0.04.</li>
|
||||
<li>No numbered badges (the <code>.ann-num</code> elements are hidden via <code>display: none</code>).</li>
|
||||
<li>Annotations remain clickable — they trigger scroll-sync to the matching paragraph.</li>
|
||||
<li>When an annotation is flash-highlighted (via scroll-sync), it briefly returns to full opacity before fading back to dimmed.</li>
|
||||
<li>Yellow comment annotations are not shown in read mode (they belong to annotate mode only).</li>
|
||||
</ul>
|
||||
|
||||
<h3>6. Status Bar</h3>
|
||||
<ul>
|
||||
<li>Positioned at the bottom of the text panel (not the full viewport).</li>
|
||||
<li>Content: <code>"{n} Abschnitte · Zuletzt bearbeitet: {userName}, {HH:mm}"</code></li>
|
||||
<li>The “Zuletzt bearbeitet” timestamp is the most recent <code>updated_at</code> across all transcription blocks for this document.</li>
|
||||
<li>The user name comes from the <code>updated_by</code> field of that most recently updated block.</li>
|
||||
<li>i18n keys: <code>transcription_status_sections</code>, <code>transcription_status_last_edited</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h3>7. Mobile Layout (viewport < 768px)</h3>
|
||||
<ul>
|
||||
<li>The side-by-side split becomes vertical: PDF strip at top, text below.</li>
|
||||
<li>PDF strip collapsed height: <code>70px</code>. Shows a miniature scan preview.</li>
|
||||
<li>Tap strip to expand (~50vh, max 300px). Tap again to collapse. Smooth CSS transition (300ms ease).</li>
|
||||
<li>Expand/collapse hint text in bottom-right corner of the strip.</li>
|
||||
<li>Mode switcher abbreviation: “Lesen | Bearb.” (i18n key: <code>mode_edit_short</code>).</li>
|
||||
<li>Scroll-sync on paragraph tap: if PDF is collapsed, auto-expand first, then scroll to annotation.</li>
|
||||
<li>Text typography: <code>15px</code> (slightly larger than desktop) with <code>line-height: 1.9</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h3>8. Empty State</h3>
|
||||
<ul>
|
||||
<li>When <code>transcription_blocks</code> count is 0, “Lesen” tab is disabled (<code>opacity: 0.35</code>, <code>cursor: not-allowed</code>, <code>aria-disabled="true"</code>).</li>
|
||||
<li>Mode defaults to <code>'transcribe'</code>.</li>
|
||||
<li>Right panel shows empty state: pencil icon in 48px sand circle, title (“Noch keine Transkription”), description (“Zeichne Bereiche auf dem Scan…”).</li>
|
||||
<li>As soon as the first block is saved, “Lesen” becomes clickable. Mode does not auto-switch.</li>
|
||||
</ul>
|
||||
|
||||
<h3>9. i18n Keys</h3>
|
||||
<table>
|
||||
<thead><tr><th>Key</th><th>de</th><th>en</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>mode_read</code></td><td>Lesen</td><td>Read</td></tr>
|
||||
<tr><td><code>mode_edit</code></td><td>Bearbeiten</td><td>Edit</td></tr>
|
||||
<tr><td><code>mode_edit_short</code></td><td>Bearb.</td><td>Edit</td></tr>
|
||||
<tr><td><code>transcription_status_sections</code></td><td>{n} Abschnitte</td><td>{n} sections</td></tr>
|
||||
<tr><td><code>transcription_status_last_edited</code></td><td>Zuletzt bearbeitet: {name}, {time}</td><td>Last edited: {name}, {time}</td></tr>
|
||||
<tr><td><code>transcription_empty_title</code></td><td>Noch keine Transkription</td><td>No transcription yet</td></tr>
|
||||
<tr><td><code>transcription_empty_desc</code></td><td>Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.</td><td>Draw regions on the scan and type the text to create a transcription.</td></tr>
|
||||
<tr><td><code>scan_expand</code></td><td>Scan vergrößern</td><td>Expand scan</td></tr>
|
||||
<tr><td><code>scan_collapse</code></td><td>Scan verkleinern</td><td>Collapse scan</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>10. Accessibility</h3>
|
||||
<ul>
|
||||
<li>Mode switcher: <code>role="tablist"</code> with <code>role="tab"</code> children, <code>aria-selected</code> on active tab.</li>
|
||||
<li>Disabled “Lesen” tab: <code>aria-disabled="true"</code>, <code>tabindex="-1"</code>.</li>
|
||||
<li>Read view text: semantic HTML — <code><article></code> wrapping <code><p></code> elements. No <code>contenteditable</code>.</li>
|
||||
<li>Paragraphs are clickable: <code>role="button"</code>, <code>tabindex="0"</code>, <code>aria-label="Abschnitt N — klicken um Scan-Position anzuzeigen"</code>.</li>
|
||||
<li>PDF strip toggle on mobile: <code>role="button"</code>, <code>aria-expanded="{expanded}"</code>, <code>aria-label="Scan {expanded ? 'verkleinern' : 'vergrößern'}"</code>.</li>
|
||||
<li>Scroll-sync animations respect <code>prefers-reduced-motion</code>: skip the 1.5s fade, apply instant highlight that disappears after 200ms.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -31,3 +31,6 @@ src/lib/paraglide
|
||||
# src/lib/generated/api.ts
|
||||
src/lib/paraglide_bak*
|
||||
/coverage
|
||||
|
||||
# Playwright auth state — regenerated at the start of each CI run via auth.setup.ts
|
||||
e2e/.auth/
|
||||
|
||||
@@ -17,6 +17,7 @@ bun.lockb
|
||||
/src/lib/generated/
|
||||
/src/lib/paraglide/
|
||||
/src/lib/paraglide_bak*/
|
||||
/src/paraglide/
|
||||
|
||||
# Test artifacts
|
||||
/test-results/
|
||||
|
||||
@@ -37,6 +37,57 @@ test.describe('Accessibility — authenticated pages', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Accessibility — dark mode (system preference)', () => {
|
||||
for (const { name, path } of AUTHENTICATED_PAGES) {
|
||||
test(`${name} page has no wcag2a/wcag2aa violations in prefers-color-scheme: dark`, async ({
|
||||
browser
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
colorScheme: 'dark',
|
||||
storageState: 'e2e/.auth/user.json'
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(path);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const results = await buildAxe(page).analyze();
|
||||
|
||||
if (results.violations.length > 0) {
|
||||
const summary = results.violations
|
||||
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
|
||||
.join('\n');
|
||||
console.log(`\nAccessibility violations on ${name} (dark/media):\n${summary}`);
|
||||
}
|
||||
|
||||
await context.close();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Accessibility — dark mode (manual toggle)', () => {
|
||||
for (const { name, path } of AUTHENTICATED_PAGES) {
|
||||
test(`${name} page has no wcag2a/wcag2aa violations with data-theme='dark'`, async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(path);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||
|
||||
const results = await buildAxe(page).analyze();
|
||||
|
||||
if (results.violations.length > 0) {
|
||||
const summary = results.violations
|
||||
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
|
||||
.join('\n');
|
||||
console.log(`\nAccessibility violations on ${name} (dark/manual):\n${summary}`);
|
||||
}
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Accessibility — login page', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
|
||||
29
frontend/e2e/dashboard-classic-split.spec.ts
Normal file
29
frontend/e2e/dashboard-classic-split.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
/**
|
||||
* Classic Split layout — verifies the right column visibility guard.
|
||||
*
|
||||
* The right column (DropZone + NeedsMetadata queue) is only rendered when
|
||||
* `canWrite === true` or there are incomplete docs. A read-only user with a
|
||||
* complete archive must never see an empty 300px ghost column.
|
||||
*/
|
||||
|
||||
test.describe('Dashboard Classic Split — write user', () => {
|
||||
test('right column is visible for admin user', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('dashboard-right-column')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Dashboard Classic Split — read-only user', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page, 'reader', 'reader123');
|
||||
});
|
||||
|
||||
test('right column is absent for read-only user with no incomplete docs', async ({ page }) => {
|
||||
await expect(page.getByTestId('dashboard-right-column')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
88
frontend/e2e/focus-rings.spec.ts
Normal file
88
frontend/e2e/focus-rings.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Expected focus ring resolved colors
|
||||
// Light: --c-focus-ring: #012851 (brand-navy)
|
||||
const FOCUS_RING_LIGHT = 'rgb(1, 40, 81)';
|
||||
// Dark: --c-focus-ring: #a1dcd8 (brand-mint)
|
||||
const FOCUS_RING_DARK = 'rgb(161, 220, 216)';
|
||||
|
||||
test.describe('Focus ring token — CSS custom property', () => {
|
||||
test('--c-focus-ring is defined in light mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const value = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--c-focus-ring').trim()
|
||||
);
|
||||
expect(value).toBe('#012851');
|
||||
});
|
||||
|
||||
test('--c-focus-ring is defined in dark mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||
|
||||
const value = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--c-focus-ring').trim()
|
||||
);
|
||||
expect(value).toBe('#a1dcd8');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Focus ring — header interactive elements', () => {
|
||||
test('ThemeToggle has brand-navy ring in light mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByRole('button', { name: /dark mode|dunkelmodus/i }).focus();
|
||||
const boxShadow = await page.evaluate(
|
||||
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
|
||||
);
|
||||
expect(boxShadow).toContain(FOCUS_RING_LIGHT);
|
||||
});
|
||||
|
||||
test('AppNav link has brand-mint ring in dark mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||
|
||||
// Focus first desktop nav link
|
||||
await page.locator('header nav').getByRole('link').first().focus();
|
||||
const boxShadow = await page.evaluate(
|
||||
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
|
||||
);
|
||||
expect(boxShadow).toContain(FOCUS_RING_DARK);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Focus ring — form inputs', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('login username input has brand-mint ring in dark mode', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||
|
||||
await page.locator('#username').focus();
|
||||
const boxShadow = await page.evaluate(
|
||||
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
|
||||
);
|
||||
expect(boxShadow).toContain(FOCUS_RING_DARK);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Focus ring — PersonTypeahead', () => {
|
||||
test('PersonTypeahead input has brand-navy ring in light mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Open advanced filter panel to expose the sender PersonTypeahead
|
||||
await page.getByRole('button', { name: /filter/i }).click();
|
||||
await page.waitForSelector('#senderId-search');
|
||||
|
||||
await page.locator('#senderId-search').focus();
|
||||
const boxShadow = await page.evaluate(
|
||||
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
|
||||
);
|
||||
expect(boxShadow).toContain(FOCUS_RING_LIGHT);
|
||||
});
|
||||
});
|
||||
118
frontend/e2e/header.spec.ts
Normal file
118
frontend/e2e/header.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
// #012851 — brand-navy, set as --c-header in layout.css (both light and dark mode)
|
||||
const BRAND_NAVY = 'rgb(1, 40, 81)';
|
||||
|
||||
test.describe('Header — brand-navy background', () => {
|
||||
test('header background is brand-navy in light mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('navigation')).toBeVisible();
|
||||
|
||||
const bg = await page.locator('header').evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(bg).toBe(BRAND_NAVY);
|
||||
});
|
||||
|
||||
test('header passes accessibility audit in light mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('navigation')).toBeVisible();
|
||||
|
||||
const results = await new AxeBuilder({ page }).include('header').analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test('header background stays brand-navy after switching to dark mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('navigation')).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('banner')
|
||||
.getByRole('button', { name: /dark mode/i })
|
||||
.click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
const bg = await page.locator('header').evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(bg).toBe(BRAND_NAVY);
|
||||
});
|
||||
|
||||
test('header passes accessibility audit in dark mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('navigation')).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('banner')
|
||||
.getByRole('button', { name: /dark mode/i })
|
||||
.click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
const results = await new AxeBuilder({ page }).include('header').analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test('logo text is visible at 375px viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('banner').getByText('Familienarchiv')).toBeVisible();
|
||||
});
|
||||
|
||||
test('hamburger menu opens on tablet viewport (768px)', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 768, height: 1024 });
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('navigation')).toBeVisible();
|
||||
|
||||
const hamburger = page.getByRole('button', { name: /menü öffnen/i });
|
||||
await expect(hamburger).toBeVisible();
|
||||
await hamburger.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('navigation', { name: /mobile/i }).or(page.locator('#mobile-nav'))
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Login page — AuthHeader', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('login page has brand-navy header with language switcher', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
const header = page.locator('header');
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
const bg = await header.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(bg).toBe(BRAND_NAVY);
|
||||
|
||||
await expect(header.getByRole('button', { name: 'DE' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('login page header passes accessibility audit', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
|
||||
const results = await new AxeBuilder({ page }).include('header').analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Forgot-password page — AuthHeader', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('forgot-password page has brand-navy header', async ({ page }) => {
|
||||
await page.goto('/forgot-password');
|
||||
|
||||
const header = page.locator('header');
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
const bg = await header.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(bg).toBe(BRAND_NAVY);
|
||||
});
|
||||
|
||||
test('forgot-password page header passes accessibility audit', async ({ page }) => {
|
||||
await page.goto('/forgot-password');
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
|
||||
const results = await new AxeBuilder({ page }).include('header').analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -80,8 +80,7 @@ test.describe('Password reset', () => {
|
||||
await page.locator('input[name="currentPassword"]').fill(newPassword);
|
||||
await page.locator('input[name="newPassword"]').fill(originalPassword);
|
||||
await page.locator('input[name="confirmPassword"]').fill(originalPassword);
|
||||
// Profile page has two "Speichern" buttons — the password form is the last one
|
||||
await page.locator('button[type="submit"]').last().click();
|
||||
await page.getByTestId('submit-password').click();
|
||||
// After changing password, auth_token is stale → redirect to login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
||||
|
||||
@@ -60,6 +60,48 @@ test.describe('Theme toggle', () => {
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
});
|
||||
|
||||
test('header uses --c-header token background in dark mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||
|
||||
const headerBg = await page.evaluate(() => {
|
||||
const header = document.querySelector('header');
|
||||
return header ? getComputedStyle(header).backgroundColor : null;
|
||||
});
|
||||
// --c-header in dark mode = #012851 (brand navy) → rgb(1, 40, 81)
|
||||
expect(headerBg).toBe('rgb(1, 40, 81)');
|
||||
});
|
||||
|
||||
test('color-scheme is dark when data-theme=dark is set', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||
|
||||
const colorScheme = await page.evaluate(
|
||||
() => getComputedStyle(document.documentElement).colorScheme
|
||||
);
|
||||
expect(colorScheme).toBe('dark');
|
||||
});
|
||||
|
||||
test('color-scheme is dark in prefers-color-scheme: dark media', async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
colorScheme: 'dark',
|
||||
storageState: 'e2e/.auth/user.json'
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const colorScheme = await page.evaluate(
|
||||
() => getComputedStyle(document.documentElement).colorScheme
|
||||
);
|
||||
await context.close();
|
||||
expect(colorScheme).toBe('dark');
|
||||
});
|
||||
|
||||
test('saved theme is applied before first paint (no flash)', async ({ page }) => {
|
||||
// Set dark theme in localStorage before navigating
|
||||
await page.goto('/');
|
||||
|
||||
@@ -223,6 +223,12 @@
|
||||
"admin_label_initial_password": "Passwort",
|
||||
"doc_file_error_preview": "Vorschau konnte nicht geladen werden.",
|
||||
"doc_download_title": "Herunterladen",
|
||||
"topbar_back_label": "Zurück zur Dokumentenliste",
|
||||
"topbar_more_actions": "Weitere Aktionen",
|
||||
"topbar_overflow_more": "+{count} weitere",
|
||||
"topbar_overflow_suffix": "weitere",
|
||||
"topbar_overflow_heading": "Weitere Empfänger",
|
||||
"topbar_overflow_show": "{count} weitere Empfänger anzeigen",
|
||||
"doc_tag_filter_title": "Nach {name} filtern",
|
||||
"doc_conversation_title": "Konversation anzeigen",
|
||||
"doc_preview_iframe_title": "Dokumentvorschau",
|
||||
@@ -309,10 +315,14 @@
|
||||
"comp_expandable_show_less": "Weniger anzeigen",
|
||||
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
|
||||
"comment_section_title": "Diskussion",
|
||||
"comment_placeholder": "Kommentar schreiben…",
|
||||
"comment_placeholder": "Kommentar schreiben… (@Name erwähnen · Enter senden)",
|
||||
"comment_btn_post": "Senden",
|
||||
"comment_btn_reply": "Antworten",
|
||||
"comment_edited_label": "· bearbeitet",
|
||||
"comment_edited_label": "(Bearbeitet)",
|
||||
"comment_time_just_now": "gerade eben",
|
||||
"comment_time_minutes": "vor {count} Minute(n)",
|
||||
"comment_time_hours": "vor {count} Stunde(n)",
|
||||
"comment_time_days": "vor {count} Tag(en)",
|
||||
"comment_panel_title": "Kommentare",
|
||||
"comment_panel_close": "Schließen",
|
||||
"doc_panel_tab_metadata": "Metadaten",
|
||||
@@ -321,6 +331,7 @@
|
||||
"doc_panel_tab_history": "Verlauf",
|
||||
"doc_panel_annotate": "Annotieren",
|
||||
"doc_panel_annotate_stop": "Fertig",
|
||||
"doc_panel_annotate_hint": "Klicken und ziehen Sie, um einen Bereich zu markieren",
|
||||
"doc_panel_annotation_thread_title": "Annotation",
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
||||
"pdf_annotations_show": "Annotierungen anzeigen",
|
||||
@@ -375,6 +386,8 @@
|
||||
"dashboard_needs_metadata_heading": "Metadaten fehlen",
|
||||
"dashboard_needs_metadata_show_all": "Alle anzeigen",
|
||||
"dashboard_recent_heading": "Zuletzt aktiv",
|
||||
"dashboard_stats_documents": "Dokumente",
|
||||
"dashboard_stats_persons": "Personen",
|
||||
"dashboard_resume_label": "Zuletzt geöffnet:",
|
||||
"dashboard_resume_fallback": "Unbekanntes Dokument",
|
||||
"doc_status_placeholder": "Platzhalter",
|
||||
@@ -414,5 +427,34 @@
|
||||
"notification_empty_history_body": "Hier erscheinen Erwähnungen und Antworten auf deine Kommentare.",
|
||||
"notification_row_aria": "{actor} {type} auf \u201e{title}\u201c \u2014 {time} \u2014 {readState}",
|
||||
"notification_read_state_read": "gelesen",
|
||||
"notification_read_state_unread": "ungelesen"
|
||||
"notification_read_state_unread": "ungelesen",
|
||||
"error_transcription_block_not_found": "Der Transkriptionsblock wurde nicht gefunden.",
|
||||
"error_transcription_block_conflict": "Der Block wurde zwischenzeitlich von jemand anderem geändert. Bitte laden Sie die Seite neu.",
|
||||
"doc_details_toggle": "Details",
|
||||
"doc_details_section_details": "Details",
|
||||
"doc_details_section_persons": "Personen",
|
||||
"doc_details_section_tags": "Schlagwörter",
|
||||
"doc_details_field_date": "Datum",
|
||||
"doc_details_field_sender": "Absender",
|
||||
"doc_details_field_receivers": "Empfänger",
|
||||
"doc_details_field_status": "Status",
|
||||
"doc_details_no_persons": "Keine Personen zugeordnet",
|
||||
"doc_details_no_tags": "Keine Schlagwörter zugeordnet",
|
||||
"doc_details_more_receivers": "+{count} weitere",
|
||||
"transcription_mode_label": "Transkribieren",
|
||||
"transcription_mode_stop": "Fertig",
|
||||
"transcription_block_placeholder": "Text hier eingeben...",
|
||||
"transcription_block_save_saving": "Speichere...",
|
||||
"transcription_block_save_saved": "Gespeichert",
|
||||
"transcription_block_save_error": "Nicht gespeichert",
|
||||
"transcription_block_save_retry": "Erneut versuchen",
|
||||
"transcription_block_comment_btn": "Kommentieren",
|
||||
"transcription_block_quote_hint": "Text markieren für Zitat",
|
||||
"transcription_block_delete_confirm": "Block und alle zugehörigen Kommentare wirklich löschen?",
|
||||
"transcription_block_history_btn": "Verlauf",
|
||||
"transcription_empty_cta": "Markiere einen Bereich auf dem Scan, um mit der Transkription zu beginnen",
|
||||
"transcription_next_block_cta": "Markiere eine weitere Passage im Scan, um Block {number} anzulegen",
|
||||
"transcription_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren",
|
||||
"transcription_quote_stale": "Zitat aus älterer Version",
|
||||
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden"
|
||||
}
|
||||
|
||||
@@ -223,6 +223,12 @@
|
||||
"admin_label_initial_password": "Password",
|
||||
"doc_file_error_preview": "Could not load preview.",
|
||||
"doc_download_title": "Download",
|
||||
"topbar_back_label": "Back to document list",
|
||||
"topbar_more_actions": "More actions",
|
||||
"topbar_overflow_more": "+{count} more",
|
||||
"topbar_overflow_suffix": "more",
|
||||
"topbar_overflow_heading": "More receivers",
|
||||
"topbar_overflow_show": "Show {count} more receivers",
|
||||
"doc_tag_filter_title": "Filter by {name}",
|
||||
"doc_conversation_title": "Show conversation",
|
||||
"doc_preview_iframe_title": "Document Preview",
|
||||
@@ -309,10 +315,14 @@
|
||||
"comp_expandable_show_less": "Show less",
|
||||
"error_comment_not_found": "The comment could not be found.",
|
||||
"comment_section_title": "Discussion",
|
||||
"comment_placeholder": "Write a comment…",
|
||||
"comment_placeholder": "Write a comment… (@name to mention · Enter to send)",
|
||||
"comment_btn_post": "Send",
|
||||
"comment_btn_reply": "Reply",
|
||||
"comment_edited_label": "· edited",
|
||||
"comment_edited_label": "(Edited)",
|
||||
"comment_time_just_now": "just now",
|
||||
"comment_time_minutes": "{count} minute(s) ago",
|
||||
"comment_time_hours": "{count} hour(s) ago",
|
||||
"comment_time_days": "{count} day(s) ago",
|
||||
"comment_panel_title": "Comments",
|
||||
"comment_panel_close": "Close",
|
||||
"doc_panel_tab_metadata": "Metadata",
|
||||
@@ -321,6 +331,7 @@
|
||||
"doc_panel_tab_history": "History",
|
||||
"doc_panel_annotate": "Annotate",
|
||||
"doc_panel_annotate_stop": "Done",
|
||||
"doc_panel_annotate_hint": "Click and drag to mark an area",
|
||||
"doc_panel_annotation_thread_title": "Annotation",
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
||||
"pdf_annotations_show": "Show annotations",
|
||||
@@ -375,6 +386,8 @@
|
||||
"dashboard_needs_metadata_heading": "Missing Metadata",
|
||||
"dashboard_needs_metadata_show_all": "Show all",
|
||||
"dashboard_recent_heading": "Recent Activity",
|
||||
"dashboard_stats_documents": "Documents",
|
||||
"dashboard_stats_persons": "Persons",
|
||||
"dashboard_resume_label": "Last opened:",
|
||||
"dashboard_resume_fallback": "Unknown document",
|
||||
"doc_status_placeholder": "Placeholder",
|
||||
@@ -414,5 +427,34 @@
|
||||
"notification_empty_history_body": "Mentions and replies to your comments will appear here.",
|
||||
"notification_row_aria": "{actor} {type} on \"{title}\" — {time} — {readState}",
|
||||
"notification_read_state_read": "read",
|
||||
"notification_read_state_unread": "unread"
|
||||
"notification_read_state_unread": "unread",
|
||||
"error_transcription_block_not_found": "Transcription block not found.",
|
||||
"error_transcription_block_conflict": "This block was modified by someone else. Please reload the page.",
|
||||
"doc_details_toggle": "Details",
|
||||
"doc_details_section_details": "Details",
|
||||
"doc_details_section_persons": "Persons",
|
||||
"doc_details_section_tags": "Tags",
|
||||
"doc_details_field_date": "Date",
|
||||
"doc_details_field_sender": "Sender",
|
||||
"doc_details_field_receivers": "Receivers",
|
||||
"doc_details_field_status": "Status",
|
||||
"doc_details_no_persons": "No persons assigned",
|
||||
"doc_details_no_tags": "No tags assigned",
|
||||
"doc_details_more_receivers": "+{count} more",
|
||||
"transcription_mode_label": "Transcribe",
|
||||
"transcription_mode_stop": "Done",
|
||||
"transcription_block_placeholder": "Type text here...",
|
||||
"transcription_block_save_saving": "Saving...",
|
||||
"transcription_block_save_saved": "Saved",
|
||||
"transcription_block_save_error": "Not saved",
|
||||
"transcription_block_save_retry": "Retry",
|
||||
"transcription_block_comment_btn": "Comment",
|
||||
"transcription_block_quote_hint": "Select text to quote",
|
||||
"transcription_block_delete_confirm": "Really delete this block and all its comments?",
|
||||
"transcription_block_history_btn": "History",
|
||||
"transcription_empty_cta": "Mark a region on the scan to start transcribing",
|
||||
"transcription_next_block_cta": "Mark another passage on the scan to create block {number}",
|
||||
"transcription_draw_tooltip": "Click and drag to mark a text region",
|
||||
"transcription_quote_stale": "Quote from an older version",
|
||||
"transcription_block_conflict": "This block was changed by someone else — please reload"
|
||||
}
|
||||
|
||||
@@ -223,6 +223,12 @@
|
||||
"admin_label_initial_password": "Contraseña",
|
||||
"doc_file_error_preview": "No se pudo cargar la vista previa.",
|
||||
"doc_download_title": "Descargar",
|
||||
"topbar_back_label": "Volver a la lista de documentos",
|
||||
"topbar_more_actions": "Más acciones",
|
||||
"topbar_overflow_more": "+{count} más",
|
||||
"topbar_overflow_suffix": "más",
|
||||
"topbar_overflow_heading": "Más destinatarios",
|
||||
"topbar_overflow_show": "Mostrar {count} destinatarios más",
|
||||
"doc_tag_filter_title": "Filtrar por {name}",
|
||||
"doc_conversation_title": "Ver conversación",
|
||||
"doc_preview_iframe_title": "Vista previa del documento",
|
||||
@@ -309,10 +315,14 @@
|
||||
"comp_expandable_show_less": "Mostrar menos",
|
||||
"error_comment_not_found": "El comentario no pudo encontrarse.",
|
||||
"comment_section_title": "Discusión",
|
||||
"comment_placeholder": "Escribe un comentario…",
|
||||
"comment_placeholder": "Escribe un comentario… (@nombre para mencionar · Enter para enviar)",
|
||||
"comment_btn_post": "Enviar",
|
||||
"comment_btn_reply": "Responder",
|
||||
"comment_edited_label": "· editado",
|
||||
"comment_edited_label": "(Editado)",
|
||||
"comment_time_just_now": "justo ahora",
|
||||
"comment_time_minutes": "hace {count} minuto(s)",
|
||||
"comment_time_hours": "hace {count} hora(s)",
|
||||
"comment_time_days": "hace {count} día(s)",
|
||||
"comment_panel_title": "Comentarios",
|
||||
"comment_panel_close": "Cerrar",
|
||||
"doc_panel_tab_metadata": "Metadatos",
|
||||
@@ -321,6 +331,7 @@
|
||||
"doc_panel_tab_history": "Historial",
|
||||
"doc_panel_annotate": "Anotar",
|
||||
"doc_panel_annotate_stop": "Listo",
|
||||
"doc_panel_annotate_hint": "Haga clic y arrastre para marcar un área",
|
||||
"doc_panel_annotation_thread_title": "Anotación",
|
||||
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
||||
"pdf_annotations_show": "Mostrar anotaciones",
|
||||
@@ -375,6 +386,8 @@
|
||||
"dashboard_needs_metadata_heading": "Metadatos incompletos",
|
||||
"dashboard_needs_metadata_show_all": "Ver todos",
|
||||
"dashboard_recent_heading": "Actividad reciente",
|
||||
"dashboard_stats_documents": "Documentos",
|
||||
"dashboard_stats_persons": "Personas",
|
||||
"dashboard_resume_label": "Último abierto:",
|
||||
"dashboard_resume_fallback": "Documento desconocido",
|
||||
"doc_status_placeholder": "Marcador",
|
||||
@@ -414,5 +427,34 @@
|
||||
"notification_empty_history_body": "Aquí aparecerán las menciones y respuestas a tus comentarios.",
|
||||
"notification_row_aria": "{actor} {type} en \"{title}\" — {time} — {readState}",
|
||||
"notification_read_state_read": "leído",
|
||||
"notification_read_state_unread": "no leído"
|
||||
"notification_read_state_unread": "no leído",
|
||||
"error_transcription_block_not_found": "Bloque de transcripción no encontrado.",
|
||||
"error_transcription_block_conflict": "Este bloque fue modificado por otra persona. Por favor, recargue la página.",
|
||||
"doc_details_toggle": "Detalles",
|
||||
"doc_details_section_details": "Detalles",
|
||||
"doc_details_section_persons": "Personas",
|
||||
"doc_details_section_tags": "Etiquetas",
|
||||
"doc_details_field_date": "Fecha",
|
||||
"doc_details_field_sender": "Remitente",
|
||||
"doc_details_field_receivers": "Destinatarios",
|
||||
"doc_details_field_status": "Estado",
|
||||
"doc_details_no_persons": "No hay personas asignadas",
|
||||
"doc_details_no_tags": "No hay etiquetas asignadas",
|
||||
"doc_details_more_receivers": "+{count} más",
|
||||
"transcription_mode_label": "Transcribir",
|
||||
"transcription_mode_stop": "Listo",
|
||||
"transcription_block_placeholder": "Escriba el texto aquí...",
|
||||
"transcription_block_save_saving": "Guardando...",
|
||||
"transcription_block_save_saved": "Guardado",
|
||||
"transcription_block_save_error": "No guardado",
|
||||
"transcription_block_save_retry": "Reintentar",
|
||||
"transcription_block_comment_btn": "Comentar",
|
||||
"transcription_block_quote_hint": "Seleccione texto para citar",
|
||||
"transcription_block_delete_confirm": "¿Realmente eliminar este bloque y todos sus comentarios?",
|
||||
"transcription_block_history_btn": "Historial",
|
||||
"transcription_empty_cta": "Marque una región en el escaneo para comenzar a transcribir",
|
||||
"transcription_next_block_cta": "Marque otro pasaje en el escaneo para crear el bloque {number}",
|
||||
"transcription_draw_tooltip": "Haga clic y arrastre para marcar una región de texto",
|
||||
"transcription_quote_stale": "Cita de una versión anterior",
|
||||
"transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue"
|
||||
}
|
||||
|
||||
64
frontend/src/lib/actions/clickOutside.svelte.spec.ts
Normal file
64
frontend/src/lib/actions/clickOutside.svelte.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
const { clickOutside } = await import('./clickOutside');
|
||||
|
||||
describe('clickOutside action', () => {
|
||||
const nodes: HTMLElement[] = [];
|
||||
|
||||
function makeNode(): HTMLElement {
|
||||
const node = document.createElement('div');
|
||||
document.body.appendChild(node);
|
||||
nodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
nodes.forEach((n) => n.remove());
|
||||
nodes.length = 0;
|
||||
});
|
||||
|
||||
it('registers a capture-phase click listener on mount', () => {
|
||||
const node = makeNode();
|
||||
const original = document.addEventListener.bind(document);
|
||||
let registered = false;
|
||||
document.addEventListener = (type: string, _fn: unknown, opts: unknown) => {
|
||||
if (type === 'click' && opts === true) registered = true;
|
||||
original(type as string, _fn as EventListener, opts as boolean);
|
||||
};
|
||||
clickOutside(node);
|
||||
expect(registered).toBe(true);
|
||||
document.addEventListener = original;
|
||||
});
|
||||
|
||||
it('dispatches clickoutside when clicking outside the node', () => {
|
||||
const node = makeNode();
|
||||
const outside = makeNode();
|
||||
let fired = false;
|
||||
node.addEventListener('clickoutside', () => (fired = true));
|
||||
clickOutside(node);
|
||||
outside.click();
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
|
||||
it('does not dispatch clickoutside when clicking inside the node', () => {
|
||||
const node = makeNode();
|
||||
const child = document.createElement('span');
|
||||
node.appendChild(child);
|
||||
let fired = false;
|
||||
node.addEventListener('clickoutside', () => (fired = true));
|
||||
clickOutside(node);
|
||||
child.click();
|
||||
expect(fired).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the listener on destroy', () => {
|
||||
const node = makeNode();
|
||||
const outside = makeNode();
|
||||
let count = 0;
|
||||
node.addEventListener('clickoutside', () => count++);
|
||||
const { destroy } = clickOutside(node);
|
||||
destroy();
|
||||
outside.click();
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
15
frontend/src/lib/actions/clickOutside.ts
Normal file
15
frontend/src/lib/actions/clickOutside.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function clickOutside(node: HTMLElement): { destroy: () => void } {
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
node.dispatchEvent(new CustomEvent('clickoutside'));
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleClick, true);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
<script lang="ts">
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
annotationId: string;
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
onClose: () => void;
|
||||
onCountChange?: (count: number) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
annotationId,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
onClose,
|
||||
onCountChange
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- Desktop / tablet panel (≥ sm): absolute overlay on the right side -->
|
||||
<div
|
||||
class="absolute top-0 right-0 z-50 hidden h-full w-80 flex-col border-l border-line bg-surface shadow-2xl sm:flex"
|
||||
>
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
|
||||
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||
{m.comment_panel_title()}
|
||||
</h3>
|
||||
<button
|
||||
onclick={onClose}
|
||||
aria-label={m.comment_panel_close()}
|
||||
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
annotationId={annotationId}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
loadOnMount={true}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile modal (< sm): fixed full-screen with slide-up sheet -->
|
||||
<div class="fixed inset-0 z-50 flex flex-col sm:hidden">
|
||||
<!-- Semi-transparent backdrop -->
|
||||
<div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div>
|
||||
|
||||
<!-- Slide-up panel -->
|
||||
<div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-surface shadow-2xl">
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
|
||||
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||
{m.comment_panel_title()}
|
||||
</h3>
|
||||
<button
|
||||
onclick={onClose}
|
||||
aria-label={m.comment_panel_close()}
|
||||
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
annotationId={annotationId}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
loadOnMount={true}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,19 +10,19 @@ type DrawRect = {
|
||||
|
||||
let {
|
||||
annotations = [],
|
||||
canAnnotate,
|
||||
canDraw,
|
||||
color,
|
||||
blockNumbers = {},
|
||||
activeAnnotationId = null,
|
||||
onDraw,
|
||||
onDelete,
|
||||
commentCounts,
|
||||
onAnnotationClick
|
||||
}: {
|
||||
annotations: Annotation[];
|
||||
canAnnotate: boolean;
|
||||
canDraw: boolean;
|
||||
color: string;
|
||||
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
|
||||
onDelete: (id: string) => void;
|
||||
commentCounts?: Record<string, number>;
|
||||
blockNumbers?: Record<string, number>;
|
||||
activeAnnotationId?: string | null;
|
||||
onDraw: (rect: DrawRect) => void;
|
||||
onAnnotationClick?: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
@@ -45,7 +45,7 @@ function getNormalizedCoords(event: PointerEvent, element: HTMLElement): { x: nu
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!canAnnotate) return;
|
||||
if (!canDraw) return;
|
||||
|
||||
if ((event.target as HTMLElement).closest('[data-annotation]')) return;
|
||||
|
||||
@@ -58,7 +58,7 @@ function handlePointerDown(event: PointerEvent) {
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!canAnnotate || !drawStart) return;
|
||||
if (!canDraw || !drawStart) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const coords = getNormalizedCoords(event, container);
|
||||
@@ -72,7 +72,7 @@ function handlePointerMove(event: PointerEvent) {
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
if (!canAnnotate || !drawStart || !drawRect) return;
|
||||
if (!canDraw || !drawStart || !drawRect) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const coords = getNormalizedCoords(event, container);
|
||||
@@ -93,7 +93,7 @@ function handlePointerUp(event: PointerEvent) {
|
||||
let hoveredId = $state<string | null>(null);
|
||||
|
||||
const containerStyle = $derived(
|
||||
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair; touch-action: none;' : ''}`
|
||||
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canDraw ? ' cursor: crosshair; touch-action: none;' : ''}`
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -110,9 +110,11 @@ const containerStyle = $derived(
|
||||
data-annotation
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Kommentare anzeigen"
|
||||
aria-label="Block anzeigen"
|
||||
onclick={() => onAnnotationClick?.(annotation.id)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id);
|
||||
}}
|
||||
onpointerenter={() => (hoveredId = annotation.id)}
|
||||
onpointerleave={() => (hoveredId = null)}
|
||||
style="
|
||||
@@ -121,71 +123,36 @@ const containerStyle = $derived(
|
||||
top: {annotation.y * 100}%;
|
||||
width: {annotation.width * 100}%;
|
||||
height: {annotation.height * 100}%;
|
||||
background-color: {hexToRgba(annotation.color, hoveredId === annotation.id ? 0.5 : 0.3)};
|
||||
box-shadow: {hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'};
|
||||
background-color: {hexToRgba(annotation.color, hoveredId === annotation.id || annotation.id === activeAnnotationId ? 0.5 : 0.3)};
|
||||
box-shadow: {annotation.id === activeAnnotationId ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'};
|
||||
opacity: {activeAnnotationId && annotation.id !== activeAnnotationId ? 0.3 : 1};
|
||||
pointer-events: auto;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
{onAnnotationClick && !canAnnotate ? 'cursor: pointer;' : ''}
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.3s ease;
|
||||
"
|
||||
>
|
||||
{#if canAnnotate}
|
||||
<button
|
||||
aria-label="Annotation löschen"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
const count = commentCounts?.[annotation.id] ?? 0;
|
||||
if (count > 0) {
|
||||
const msg =
|
||||
count === 1
|
||||
? 'Diese Annotation hat 1 Kommentar. Beim Löschen wird er ebenfalls entfernt. Fortfahren?'
|
||||
: `Diese Annotation hat ${count} Kommentare. Beim Löschen werden sie ebenfalls entfernt. Fortfahren?`;
|
||||
if (!window.confirm(msg)) return;
|
||||
}
|
||||
onDelete(annotation.id);
|
||||
}}
|
||||
{#if blockNumbers[annotation.id]}
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
left: -8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
background-color: {annotation.color};
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-family: sans-serif;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
pointer-events: auto;
|
||||
">×</button
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
"
|
||||
>
|
||||
{/if}
|
||||
{#if (commentCounts?.[annotation.id] ?? 0) > 0}
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
bottom: -10px;
|
||||
right: -10px;
|
||||
background-color: #002850;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-family: sans-serif;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
line-height: 18px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
"
|
||||
>
|
||||
{commentCounts?.[annotation.id]}
|
||||
{blockNumbers[annotation.id]}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ type Annotation = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function makeAnnotation(id = 'ann-1'): Annotation {
|
||||
function makeAnnotation(id = 'ann-1', color = '#00C7B1'): Annotation {
|
||||
return {
|
||||
id,
|
||||
documentId: 'doc-1',
|
||||
@@ -27,7 +27,7 @@ function makeAnnotation(id = 'ann-1'): Annotation {
|
||||
y: 0.1,
|
||||
width: 0.3,
|
||||
height: 0.2,
|
||||
color: '#ff0000',
|
||||
color,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -36,39 +36,77 @@ describe('AnnotationLayer', () => {
|
||||
it('renders a colored element for each annotation', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
|
||||
canAnnotate: false,
|
||||
color: '#ff0000',
|
||||
onDraw: () => {},
|
||||
onDelete: () => {}
|
||||
canDraw: false,
|
||||
color: '#00C7B1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a delete button for each annotation when canAnnotate is true', async () => {
|
||||
it('has crosshair cursor when canDraw is true', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1')],
|
||||
canAnnotate: true,
|
||||
color: '#ff0000',
|
||||
onDraw: () => {},
|
||||
onDelete: () => {}
|
||||
annotations: [],
|
||||
canDraw: true,
|
||||
color: '#00C7B1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /annotation löschen/i }))
|
||||
.toBeInTheDocument();
|
||||
const container = document.querySelector('[role="presentation"]')!;
|
||||
expect(container.getAttribute('style')).toContain('cursor: crosshair');
|
||||
});
|
||||
|
||||
it('does not show delete buttons when canAnnotate is false', async () => {
|
||||
it('does not have crosshair cursor when canDraw is false', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1')],
|
||||
canAnnotate: false,
|
||||
color: '#ff0000',
|
||||
onDraw: () => {},
|
||||
onDelete: () => {}
|
||||
annotations: [],
|
||||
canDraw: false,
|
||||
color: '#00C7B1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
expect(page.getByRole('button', { name: /annotation löschen/i }).query()).toBeNull();
|
||||
const container = document.querySelector('[role="presentation"]')!;
|
||||
expect(container.getAttribute('style')).not.toContain('cursor: crosshair');
|
||||
});
|
||||
|
||||
it('dims non-active annotations when activeAnnotationId is set', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
|
||||
canDraw: false,
|
||||
color: '#00C7B1',
|
||||
activeAnnotationId: 'ann-1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const active = page.getByTestId('annotation-ann-1').element();
|
||||
const dimmed = page.getByTestId('annotation-ann-2').element();
|
||||
expect(active.style.opacity).toBe('1');
|
||||
expect(dimmed.style.opacity).toBe('0.3');
|
||||
});
|
||||
|
||||
it('shows all annotations at full opacity when no activeAnnotationId', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
|
||||
canDraw: false,
|
||||
color: '#00C7B1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const el1 = page.getByTestId('annotation-ann-1').element();
|
||||
const el2 = page.getByTestId('annotation-ann-2').element();
|
||||
expect(el1.style.opacity).toBe('1');
|
||||
expect(el2.style.opacity).toBe('1');
|
||||
});
|
||||
|
||||
it('does not show delete buttons (annotations owned by blocks)', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1')],
|
||||
canDraw: true,
|
||||
color: '#00C7B1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||
expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
activeAnnotationId: string | null;
|
||||
activeAnnotationPage: number | null;
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
targetCommentId?: string | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
activeAnnotationId,
|
||||
activeAnnotationPage,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
targetCommentId = null,
|
||||
onClose
|
||||
}: Props = $props();
|
||||
|
||||
const visible = $derived(activeAnnotationId !== null);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 z-10 flex w-80 flex-col border-l border-line bg-surface shadow-[-4px_0_16px_rgba(0,0,0,0.08)] transition-transform duration-200 {visible
|
||||
? 'translate-x-0'
|
||||
: 'pointer-events-none translate-x-full'}"
|
||||
data-testid="annotation-side-panel"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
|
||||
<span class="font-sans text-xs font-medium text-ink">
|
||||
{m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })}
|
||||
</span>
|
||||
<button
|
||||
onclick={onClose}
|
||||
aria-label={m.comment_panel_close()}
|
||||
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comment thread -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{#if activeAnnotationId}
|
||||
{#key activeAnnotationId}
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
annotationId={activeAnnotationId}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
targetCommentId={targetCommentId}
|
||||
loadOnMount={true}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import AnnotationSidePanel from './AnnotationSidePanel.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => []
|
||||
})
|
||||
);
|
||||
|
||||
const baseProps = {
|
||||
documentId: 'doc-1',
|
||||
activeAnnotationPage: 1,
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
canAdmin: false,
|
||||
onClose: vi.fn()
|
||||
};
|
||||
|
||||
describe('AnnotationSidePanel – visibility', () => {
|
||||
it('is hidden (translated off-screen) when activeAnnotationId is null', async () => {
|
||||
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: null });
|
||||
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
|
||||
expect(panel?.classList.contains('translate-x-full')).toBe(true);
|
||||
expect(panel?.classList.contains('translate-x-0')).toBe(false);
|
||||
});
|
||||
|
||||
it('is visible when activeAnnotationId is set', async () => {
|
||||
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1' });
|
||||
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
|
||||
expect(panel?.classList.contains('translate-x-0')).toBe(true);
|
||||
expect(panel?.classList.contains('translate-x-full')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnnotationSidePanel – close button', () => {
|
||||
it('calls onClose when the close button is clicked', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1', onClose });
|
||||
await page.getByRole('button', { name: /schließen/i }).click();
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnnotationSidePanel – targetCommentId forwarding', () => {
|
||||
it('renders CommentThread when annotation is active', async () => {
|
||||
render(AnnotationSidePanel, {
|
||||
...baseProps,
|
||||
activeAnnotationId: 'ann-1',
|
||||
targetCommentId: 'comment-42'
|
||||
});
|
||||
// CommentThread renders inside the panel when activeAnnotationId is set
|
||||
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel?.classList.contains('translate-x-0')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render CommentThread when annotation is null', async () => {
|
||||
render(AnnotationSidePanel, {
|
||||
...baseProps,
|
||||
activeAnnotationId: null,
|
||||
targetCommentId: 'comment-42'
|
||||
});
|
||||
// Panel is hidden and no fetch should have been triggered for comments
|
||||
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
|
||||
expect(panel?.classList.contains('translate-x-full')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick, untrack } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { Comment, CommentReply } from '$lib/types';
|
||||
import type { Comment } from '$lib/types';
|
||||
import MentionEditor from '$lib/components/MentionEditor.svelte';
|
||||
import { renderBody, extractContent } from '$lib/utils/mention';
|
||||
import type { MentionDTO } from '$lib/types';
|
||||
@@ -9,62 +9,95 @@ import type { MentionDTO } from '$lib/types';
|
||||
type Props = {
|
||||
documentId: string;
|
||||
annotationId?: string | null;
|
||||
blockId?: string | null;
|
||||
initialComments?: Comment[];
|
||||
loadOnMount?: boolean;
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
targetCommentId?: string | null;
|
||||
quotedText?: string | null;
|
||||
showCompose?: boolean;
|
||||
onCountChange?: (count: number) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
annotationId = null,
|
||||
blockId = null,
|
||||
initialComments = [],
|
||||
loadOnMount = false,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
targetCommentId = null,
|
||||
currentUserId = null,
|
||||
quotedText = null,
|
||||
showCompose = true,
|
||||
onCountChange
|
||||
}: Props = $props();
|
||||
|
||||
type FlatMessage = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
mentionDTOs?: MentionDTO[];
|
||||
};
|
||||
|
||||
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
||||
let highlightedCommentId: string | null = $state(untrack(() => targetCommentId ?? null));
|
||||
let newText: string = $state('');
|
||||
let replyingTo: string | null = $state(null);
|
||||
let replyText: string = $state('');
|
||||
let editingId: string | null = $state(null);
|
||||
let editText: string = $state('');
|
||||
let posting: boolean = $state(false);
|
||||
let newMentionCandidates: MentionDTO[] = $state([]);
|
||||
let replyMentionCandidates: MentionDTO[] = $state([]);
|
||||
let editMentionCandidates: MentionDTO[] = $state([]);
|
||||
let editingId: string | null = $state(null);
|
||||
let editText: string = $state('');
|
||||
|
||||
const commentsBase = $derived(
|
||||
annotationId
|
||||
? `/api/documents/${documentId}/annotations/${annotationId}/comments`
|
||||
: `/api/documents/${documentId}/comments`
|
||||
blockId
|
||||
? `/api/documents/${documentId}/transcription-blocks/${blockId}/comments`
|
||||
: annotationId
|
||||
? `/api/documents/${documentId}/annotations/${annotationId}/comments`
|
||||
: `/api/documents/${documentId}/comments`
|
||||
);
|
||||
|
||||
const flatMessages = $derived(
|
||||
comments.flatMap((thread) => [thread as FlatMessage, ...(thread.replies as FlatMessage[])])
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (quotedText && quotedText.trim()) {
|
||||
newText = `> "${quotedText}"\n\n`;
|
||||
}
|
||||
});
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'gerade eben';
|
||||
if (minutes < 60) return `vor ${minutes} Minute${minutes === 1 ? '' : 'n'}`;
|
||||
if (minutes < 1) return m.comment_time_just_now();
|
||||
if (minutes < 60) return m.comment_time_minutes({ count: minutes });
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `vor ${hours} Stunde${hours === 1 ? '' : 'n'}`;
|
||||
if (hours < 24) return m.comment_time_hours({ count: hours });
|
||||
const days = Math.floor(hours / 24);
|
||||
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
|
||||
return m.comment_time_days({ count: days });
|
||||
}
|
||||
|
||||
function wasEdited(c: { createdAt: string; updatedAt: string }): boolean {
|
||||
return c.updatedAt > c.createdAt;
|
||||
}
|
||||
|
||||
function canModify(c: { authorId: string | null }): boolean {
|
||||
return (currentUserId != null && c.authorId === currentUserId) || canAdmin;
|
||||
function isOwn(c: { authorId: string | null }): boolean {
|
||||
return currentUserId !== null && c.authorId === currentUserId;
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((w) => w.charAt(0).toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
function extractQuote(content: string): { quote: string | null; body: string } {
|
||||
const match = content.match(/^>\s*"(.+?)"\s*\n\n?([\s\S]*)$/);
|
||||
if (match) return { quote: match[1], body: match[2] };
|
||||
return { quote: null, body: content };
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
@@ -101,26 +134,9 @@ async function postComment() {
|
||||
}
|
||||
}
|
||||
|
||||
async function postReply(threadId: string) {
|
||||
const text = replyText.trim();
|
||||
if (!text || posting) return;
|
||||
posting = true;
|
||||
try {
|
||||
const { content, mentionedUserIds } = extractContent(text, replyMentionCandidates);
|
||||
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, mentionedUserIds })
|
||||
});
|
||||
if (res.ok) {
|
||||
replyText = '';
|
||||
replyMentionCandidates = [];
|
||||
replyingTo = null;
|
||||
await reload();
|
||||
}
|
||||
} finally {
|
||||
posting = false;
|
||||
}
|
||||
function startEdit(msg: FlatMessage) {
|
||||
editingId = msg.id;
|
||||
editText = msg.content;
|
||||
}
|
||||
|
||||
async function saveEdit(commentId: string) {
|
||||
@@ -128,15 +144,14 @@ async function saveEdit(commentId: string) {
|
||||
if (!text || posting) return;
|
||||
posting = true;
|
||||
try {
|
||||
const { content, mentionedUserIds } = extractContent(text, editMentionCandidates);
|
||||
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, mentionedUserIds })
|
||||
body: JSON.stringify({ content: text })
|
||||
});
|
||||
if (res.ok) {
|
||||
editingId = null;
|
||||
editMentionCandidates = [];
|
||||
editText = '';
|
||||
await reload();
|
||||
}
|
||||
} finally {
|
||||
@@ -144,6 +159,21 @@ async function saveEdit(commentId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
editText = '';
|
||||
}
|
||||
|
||||
function handleEditKeydown(e: KeyboardEvent, commentId: string) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
saveEdit(commentId);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
cancelEdit();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(commentId: string) {
|
||||
if (posting) return;
|
||||
posting = true;
|
||||
@@ -159,230 +189,121 @@ async function deleteComment(commentId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(comment: Comment | CommentReply) {
|
||||
editingId = comment.id;
|
||||
editText = comment.content;
|
||||
editMentionCandidates = [];
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
editText = '';
|
||||
}
|
||||
|
||||
function startReply(threadId: string) {
|
||||
replyingTo = threadId;
|
||||
replyText = '';
|
||||
}
|
||||
|
||||
function cancelReply() {
|
||||
replyingTo = null;
|
||||
replyText = '';
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
onMount(() => {
|
||||
if (loadOnMount) {
|
||||
reload();
|
||||
} else {
|
||||
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
||||
onCountChange?.(total);
|
||||
}
|
||||
|
||||
if (targetCommentId) {
|
||||
await tick();
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
|
||||
// Remove highlight on first user interaction
|
||||
const clearHighlight = () => {
|
||||
highlightedCommentId = null;
|
||||
document.removeEventListener('click', clearHighlight, true);
|
||||
document.removeEventListener('keydown', clearHighlight, true);
|
||||
document.removeEventListener('scroll', clearHighlight, true);
|
||||
};
|
||||
document.addEventListener('click', clearHighlight, true);
|
||||
document.addEventListener('keydown', clearHighlight, true);
|
||||
document.addEventListener('scroll', clearHighlight, true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Renders a single comment or reply entry.
|
||||
showReplyButton: whether the "Reply" button appears (only on last item in a thread).
|
||||
-->
|
||||
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
|
||||
{#if editingId === comment.id}
|
||||
<div class="flex flex-col gap-2">
|
||||
<MentionEditor
|
||||
bind:value={editText}
|
||||
bind:mentionCandidates={editMentionCandidates}
|
||||
rows={3}
|
||||
disabled={posting}
|
||||
onsubmit={() => saveEdit(comment.id)}
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||
disabled={posting}
|
||||
onclick={() => saveEdit(comment.id)}
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={cancelEdit}
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-sans text-xs font-semibold text-ink">{comment.authorName}</span>
|
||||
<span class="font-sans text-xs text-ink-3">{timeAgo(comment.createdAt)}</span>
|
||||
{#if wasEdited(comment)}
|
||||
<span class="font-sans text-xs text-ink-3">
|
||||
{m.comment_edited_label()}
|
||||
{timeAgo(comment.updatedAt)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
|
||||
{@html renderBody(comment.content, comment.mentionDTOs ?? [])}
|
||||
</p>
|
||||
</div>
|
||||
{#if canModify(comment)}
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={() => startEdit(comment)}
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={() => deleteComment(comment.id)}
|
||||
>
|
||||
{m.btn_delete()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showReplyButton && canComment}
|
||||
<div class="mt-1">
|
||||
<button
|
||||
class="font-sans text-xs font-medium text-primary transition-colors hover:text-ink-2"
|
||||
onclick={() => startReply(threadId)}
|
||||
>
|
||||
{m.comment_btn_reply()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if comments.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 py-8 text-center">
|
||||
{#if flatMessages.length > 0}
|
||||
<div class="rounded border-l-2 border-accent bg-muted p-2">
|
||||
<div class="mb-2 flex items-center gap-1.5 font-sans text-sm font-semibold text-ink-2">
|
||||
<svg
|
||||
class="h-10 w-10 text-ink-3"
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
|
||||
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="font-sans text-sm text-ink-3">{m.comment_empty_hint()}</p>
|
||||
{flatMessages.length}
|
||||
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
|
||||
</div>
|
||||
{/if}
|
||||
{#each comments as thread, ti (thread.id)}
|
||||
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
|
||||
<!-- Root comment -->
|
||||
<div
|
||||
data-comment-id={thread.id}
|
||||
class={highlightedCommentId === thread.id
|
||||
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
|
||||
: ''}
|
||||
>
|
||||
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
{#each thread.replies as reply, ri (reply.id)}
|
||||
<div
|
||||
data-comment-id={reply.id}
|
||||
class="mt-3 ml-6 border-l-2 border-line pl-4 {highlightedCommentId === reply.id
|
||||
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
|
||||
: ''}"
|
||||
>
|
||||
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="space-y-2">
|
||||
{#each flatMessages as msg (msg.id)}
|
||||
{@const parsed = extractQuote(msg.content)}
|
||||
<div class="flex gap-2">
|
||||
<div
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
|
||||
>
|
||||
{getInitials(msg.authorName)}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-sans text-sm font-semibold text-ink">{msg.authorName}</span>
|
||||
{#if wasEdited(msg)}
|
||||
<span class="font-sans text-xs text-ink-3"
|
||||
>{timeAgo(msg.updatedAt)} {m.comment_edited_label()}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="font-sans text-xs text-ink-3">{timeAgo(msg.createdAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if parsed.quote}
|
||||
<div class="my-1 border-l-2 border-line pl-2 font-serif text-sm text-ink-3 italic">
|
||||
“{parsed.quote}”
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Reply compose box -->
|
||||
{#if replyingTo === thread.id}
|
||||
<div class="mt-3 ml-6 flex flex-col gap-2">
|
||||
<MentionEditor
|
||||
bind:value={replyText}
|
||||
bind:mentionCandidates={replyMentionCandidates}
|
||||
rows={3}
|
||||
placeholder={m.comment_placeholder()}
|
||||
disabled={posting}
|
||||
onsubmit={() => postReply(thread.id)}
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||
disabled={posting}
|
||||
onclick={() => postReply(thread.id)}
|
||||
>
|
||||
{m.comment_btn_post()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={cancelReply}
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
{#if editingId === msg.id}
|
||||
<textarea
|
||||
class="mt-1 w-full resize-none rounded border border-line bg-surface px-2 py-1 font-serif text-sm leading-relaxed text-ink outline-none focus:border-primary"
|
||||
rows={2}
|
||||
bind:value={editText}
|
||||
onkeydown={(e) => handleEditKeydown(e, msg.id)}
|
||||
></textarea>
|
||||
<div class="mt-1 font-sans text-xs text-ink-3">Enter speichern · Esc abbrechen</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="relative" onclick={() => { if (isOwn(msg)) startEdit(msg); }}>
|
||||
<p
|
||||
class="font-serif text-sm leading-relaxed text-ink-2 {isOwn(msg) ? '-mx-1 cursor-text rounded px-1 transition-colors hover:bg-surface' : ''}"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
|
||||
{@html renderBody(parsed.body, msg.mentionDTOs ?? [])}
|
||||
</p>
|
||||
{#if isOwn(msg)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-error absolute -right-1 -bottom-1 cursor-pointer rounded p-0.5 text-ink-3 transition-colors"
|
||||
title={m.btn_delete()}
|
||||
aria-label={m.btn_delete()}
|
||||
onclick={(e) => { e.stopPropagation(); deleteComment(msg.id); }}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- New top-level comment -->
|
||||
{#if canComment}
|
||||
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<MentionEditor
|
||||
bind:value={newText}
|
||||
bind:mentionCandidates={newMentionCandidates}
|
||||
rows={3}
|
||||
placeholder={m.comment_placeholder()}
|
||||
disabled={posting}
|
||||
onsubmit={postComment}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||
disabled={posting || !newText.trim()}
|
||||
onclick={postComment}
|
||||
>
|
||||
{m.comment_btn_post()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if canComment && (showCompose || flatMessages.length > 0)}
|
||||
<div class="mt-2">
|
||||
<MentionEditor
|
||||
bind:value={newText}
|
||||
bind:mentionCandidates={newMentionCandidates}
|
||||
rows={1}
|
||||
placeholder={m.comment_placeholder()}
|
||||
disabled={posting}
|
||||
onsubmit={postComment}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
type NotificationDTO = {
|
||||
id: string;
|
||||
type: 'REPLY' | 'MENTION';
|
||||
documentId?: string;
|
||||
referenceId?: string;
|
||||
annotationId?: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
actorName?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
mentions: NotificationDTO[];
|
||||
}
|
||||
|
||||
let { mentions }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if mentions.length > 0}
|
||||
<div data-testid="dashboard-mentions" class="rounded-sm border border-line bg-surface p-6">
|
||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.dashboard_notifications_heading()}
|
||||
</h2>
|
||||
<div>
|
||||
{#each mentions as mention (mention.id)}
|
||||
<div class="flex items-center gap-3 border-b border-line py-2 last:border-0">
|
||||
{#if mention.documentId}
|
||||
<a
|
||||
href={mention.annotationId
|
||||
? `/documents/${mention.documentId}?commentId=${mention.referenceId}&annotationId=${mention.annotationId}`
|
||||
: `/documents/${mention.documentId}?commentId=${mention.referenceId}`}
|
||||
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
|
||||
>{mention.actorName ?? ''}</a
|
||||
>
|
||||
<span class="font-sans text-xs text-gray-400">
|
||||
{mention.type === 'MENTION'
|
||||
? m.dashboard_notification_mentioned()
|
||||
: m.dashboard_notification_replied()}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="font-serif text-lg text-ink">{mention.actorName ?? ''}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-4 border-t border-line pt-4">
|
||||
<a
|
||||
href="/notifications"
|
||||
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>{m.notification_history_view_link()}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import DashboardMentions from './DashboardMentions.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type NotificationDTO = {
|
||||
id: string;
|
||||
type: 'REPLY' | 'MENTION';
|
||||
documentId?: string;
|
||||
referenceId?: string;
|
||||
annotationId?: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
actorName?: string;
|
||||
};
|
||||
|
||||
function makeMention(overrides: Partial<NotificationDTO> = {}): NotificationDTO {
|
||||
return {
|
||||
id: 'notif-1',
|
||||
type: 'MENTION',
|
||||
documentId: 'doc-abc',
|
||||
referenceId: 'comment-xyz',
|
||||
read: false,
|
||||
createdAt: '2026-01-15T10:00:00Z',
|
||||
actorName: 'Anna Schmidt',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('DashboardMentions', () => {
|
||||
it('renders nothing when mentions list is empty', async () => {
|
||||
render(DashboardMentions, { mentions: [] });
|
||||
const widget = page.getByTestId('dashboard-mentions');
|
||||
await expect.element(widget).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a heading when mentions are present', async () => {
|
||||
render(DashboardMentions, { mentions: [makeMention()] });
|
||||
const widget = page.getByTestId('dashboard-mentions');
|
||||
await expect.element(widget).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('builds link with commentId param when no annotationId', async () => {
|
||||
render(DashboardMentions, {
|
||||
mentions: [makeMention({ documentId: 'doc-1', referenceId: 'cmt-1' })]
|
||||
});
|
||||
const link = page.getByRole('link');
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/doc-1?commentId=cmt-1');
|
||||
});
|
||||
|
||||
it('builds link with commentId and annotationId when annotationId is present', async () => {
|
||||
render(DashboardMentions, {
|
||||
mentions: [makeMention({ documentId: 'doc-2', referenceId: 'cmt-2', annotationId: 'ann-9' })]
|
||||
});
|
||||
const link = page.getByRole('link');
|
||||
await expect
|
||||
.element(link)
|
||||
.toHaveAttribute('href', '/documents/doc-2?commentId=cmt-2&annotationId=ann-9');
|
||||
});
|
||||
|
||||
it('shows actor name in each row', async () => {
|
||||
render(DashboardMentions, { mentions: [makeMention({ actorName: 'Maria Müller' })] });
|
||||
await expect.element(page.getByText('Maria Müller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "replied" label for REPLY type', async () => {
|
||||
render(DashboardMentions, { mentions: [makeMention({ type: 'REPLY' })] });
|
||||
const widget = page.getByTestId('dashboard-mentions');
|
||||
await expect.element(widget).toBeInTheDocument();
|
||||
const link = page.getByRole('link');
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a span instead of a link when documentId is absent', async () => {
|
||||
render(DashboardMentions, {
|
||||
mentions: [makeMention({ documentId: undefined, actorName: 'Lena Bauer' })]
|
||||
});
|
||||
await expect.element(page.getByText('Lena Bauer')).toBeInTheDocument();
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Document = {
|
||||
id: string;
|
||||
@@ -9,11 +10,14 @@ type Document = {
|
||||
sender?: { id: string; firstName: string; lastName: string };
|
||||
};
|
||||
|
||||
type StatsDTO = components['schemas']['StatsDTO'];
|
||||
|
||||
interface Props {
|
||||
recentDocs: Document[];
|
||||
stats?: StatsDTO | null;
|
||||
}
|
||||
|
||||
let { recentDocs }: Props = $props();
|
||||
let { recentDocs, stats = null }: Props = $props();
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
// updatedAt is a full ISO datetime — no T12:00:00 noon-anchor needed here
|
||||
@@ -31,7 +35,10 @@ function formatDate(dateStr: string): string {
|
||||
{m.dashboard_recent_heading()}
|
||||
</h2>
|
||||
{#each recentDocs as doc (doc.id)}
|
||||
<div class="flex items-center justify-between border-b border-line py-2 last:border-0">
|
||||
<div
|
||||
data-testid="doc-row-{doc.id}"
|
||||
class="flex min-h-[44px] items-center justify-between border-b border-line py-2 last:border-0"
|
||||
>
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
|
||||
@@ -48,5 +55,12 @@ function formatDate(dateStr: string): string {
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if stats?.totalDocuments != null}
|
||||
<p data-testid="dashboard-stats-footnote" class="mt-4 font-sans text-sm text-ink-3">
|
||||
{stats.totalDocuments}
|
||||
{m.dashboard_stats_documents()} · {stats.totalPersons}
|
||||
{m.dashboard_stats_persons()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -55,3 +55,40 @@ describe('DashboardRecentDocuments', () => {
|
||||
await expect.element(dateEl).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardRecentDocuments — stats footnote', () => {
|
||||
it('renders stats footnote when stats.totalDocuments is provided', async () => {
|
||||
render(DashboardRecentDocuments, {
|
||||
recentDocs: [makeDoc('d1', 'Taufschein')],
|
||||
stats: { totalDocuments: 248, totalPersons: 34 }
|
||||
});
|
||||
const footnote = page.getByTestId('dashboard-stats-footnote');
|
||||
await expect.element(footnote).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits stats footnote when stats is null', async () => {
|
||||
render(DashboardRecentDocuments, {
|
||||
recentDocs: [makeDoc('d1', 'Taufschein')],
|
||||
stats: null
|
||||
});
|
||||
const footnote = page.getByTestId('dashboard-stats-footnote');
|
||||
await expect.element(footnote).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "0 Documents" when totalDocuments is 0', async () => {
|
||||
render(DashboardRecentDocuments, {
|
||||
recentDocs: [makeDoc('d1', 'Taufschein')],
|
||||
stats: { totalDocuments: 0, totalPersons: 0 }
|
||||
});
|
||||
const footnote = page.getByTestId('dashboard-stats-footnote');
|
||||
await expect.element(footnote).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardRecentDocuments — touch targets', () => {
|
||||
it('each doc row has min-h-[44px] class for WCAG touch target', async () => {
|
||||
render(DashboardRecentDocuments, { recentDocs: [makeDoc('d1', 'Taufschein')] });
|
||||
const row = page.getByTestId('doc-row-d1');
|
||||
await expect.element(row).toHaveClass('min-h-[44px]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import PanelMetadata from './PanelMetadata.svelte';
|
||||
import PanelTranscription from './PanelTranscription.svelte';
|
||||
import PanelDiscussion from './PanelDiscussion.svelte';
|
||||
import PanelHistory from './PanelHistory.svelte';
|
||||
import type { Comment, DocumentPanelTab } from '$lib/types';
|
||||
|
||||
type Doc = {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
documentDate?: string | null;
|
||||
location?: string | null;
|
||||
documentLocation?: string | null;
|
||||
tags?: { id: string; name: string }[] | null;
|
||||
sender?: { id: string; firstName: string; lastName: string; alias?: string | null } | null;
|
||||
receivers?: { id: string; firstName: string; lastName: string }[] | null;
|
||||
summary?: string | null;
|
||||
transcription?: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
comments: Comment[];
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
open: boolean;
|
||||
height: number;
|
||||
activeTab: DocumentPanelTab;
|
||||
targetCommentId?: string | null;
|
||||
};
|
||||
|
||||
let {
|
||||
doc,
|
||||
comments,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
open = $bindable(),
|
||||
height = $bindable(),
|
||||
activeTab = $bindable(),
|
||||
targetCommentId = null
|
||||
}: Props = $props();
|
||||
|
||||
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
|
||||
|
||||
let isDragging = $state(false);
|
||||
let dragStartY = 0;
|
||||
let dragStartHeight = 0;
|
||||
|
||||
function fullHeight() {
|
||||
const topbar = document.querySelector('[data-topbar]');
|
||||
return window.innerHeight - (topbar?.getBoundingClientRect().bottom ?? 0);
|
||||
}
|
||||
|
||||
function openTab(tab: DocumentPanelTab) {
|
||||
activeTab = tab;
|
||||
if (!open) {
|
||||
open = true;
|
||||
if (height <= MIN_HEIGHT) height = fullHeight();
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
function onDragStart(e: PointerEvent) {
|
||||
isDragging = true;
|
||||
dragStartY = e.clientY;
|
||||
dragStartHeight = open ? height : MIN_HEIGHT;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function onDragMove(e: PointerEvent) {
|
||||
if (!isDragging) return;
|
||||
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
|
||||
const newHeight = dragStartHeight + delta;
|
||||
const maxHeight = fullHeight();
|
||||
|
||||
if (newHeight <= MIN_HEIGHT + 20) {
|
||||
// collapsed past threshold → close
|
||||
open = false;
|
||||
} else {
|
||||
open = true;
|
||||
height = Math.max(80, Math.min(newHeight, maxHeight));
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
const tabs: { id: DocumentPanelTab; label: () => string }[] = [
|
||||
{ id: 'metadata', label: m.doc_panel_tab_metadata },
|
||||
{ id: 'transcription', label: m.doc_panel_tab_transcription },
|
||||
{ id: 'discussion', label: m.doc_panel_tab_discussion },
|
||||
{ id: 'history', label: m.doc_panel_tab_history }
|
||||
];
|
||||
|
||||
const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
||||
|
||||
let discussionCount = $state((() => comments.reduce((s, c) => s + 1 + c.replies.length, 0))());
|
||||
|
||||
function handleCountChange(count: number) {
|
||||
discussionCount = count;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="z-30 flex shrink-0 flex-col border-t border-line bg-surface shadow-[0_-4px_16px_rgba(0,0,0,0.08)]"
|
||||
style="height: {panelHeight}px"
|
||||
data-testid="bottom-panel"
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
class="flex h-2 shrink-0 cursor-ns-resize items-center justify-center bg-surface"
|
||||
style="touch-action: none"
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Panel resize"
|
||||
onpointerdown={onDragStart}
|
||||
onpointermove={onDragMove}
|
||||
onpointerup={onDragEnd}
|
||||
onpointercancel={onDragEnd}
|
||||
>
|
||||
<div class="h-1 w-12 rounded-full bg-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="flex shrink-0 items-center border-b border-line bg-surface">
|
||||
<!-- Scrollable tabs area — hides scrollbar visually -->
|
||||
<div
|
||||
class="flex flex-1 items-center overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
onclick={() => openTab(tab.id)}
|
||||
class="mr-1 shrink-0 px-3 py-2.5 font-sans text-xs font-medium transition-colors {activeTab === tab.id && open
|
||||
? 'border-b-2 border-primary text-ink'
|
||||
: 'text-ink-3 hover:text-ink'}"
|
||||
aria-pressed={activeTab === tab.id && open}
|
||||
>
|
||||
{tab.label()}
|
||||
{#if tab.id === 'discussion'}
|
||||
<span
|
||||
data-testid="discussion-count-badge"
|
||||
class="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-sans text-[10px] font-bold text-primary-fg"
|
||||
>{discussionCount}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<button
|
||||
onclick={closePanel}
|
||||
data-testid="panel-close-btn"
|
||||
aria-label="Panel schließen"
|
||||
class="mr-2 shrink-0 rounded p-1.5 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
{#if open}
|
||||
<div class="flex-1 overflow-y-auto" data-testid="bottom-panel-content">
|
||||
{#if activeTab === 'metadata'}
|
||||
<PanelMetadata doc={doc} />
|
||||
{:else if activeTab === 'transcription'}
|
||||
<PanelTranscription doc={doc} />
|
||||
{:else if activeTab === 'discussion'}
|
||||
<PanelDiscussion
|
||||
documentId={doc.id}
|
||||
initialComments={comments}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
targetCommentId={targetCommentId}
|
||||
onCountChange={handleCountChange}
|
||||
/>
|
||||
{:else if activeTab === 'history'}
|
||||
<PanelHistory documentId={doc.id} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentBottomPanel from './DocumentBottomPanel.svelte';
|
||||
import type { Comment } from '$lib/types';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function makeComment(id: string): Comment {
|
||||
return {
|
||||
id,
|
||||
authorId: 'user-1',
|
||||
authorName: 'Alice',
|
||||
content: 'Hello',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
replies: []
|
||||
};
|
||||
}
|
||||
|
||||
const doc = { id: 'doc-1', title: 'Test' };
|
||||
|
||||
const baseProps = {
|
||||
doc,
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
canAdmin: false,
|
||||
height: 300,
|
||||
activeTab: 'discussion' as const
|
||||
};
|
||||
|
||||
describe('DocumentBottomPanel – discussion badge', () => {
|
||||
it('always shows a badge on the Discussion tab', async () => {
|
||||
render(DocumentBottomPanel, { ...baseProps, comments: [], open: true });
|
||||
await expect.element(page.getByTestId('discussion-count-badge')).toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('shows the correct count when comments exist', async () => {
|
||||
render(DocumentBottomPanel, {
|
||||
...baseProps,
|
||||
comments: [makeComment('c-1'), makeComment('c-2')],
|
||||
open: true
|
||||
});
|
||||
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('2');
|
||||
});
|
||||
});
|
||||
146
frontend/src/lib/components/DocumentMetadataDrawer.svelte
Normal file
146
frontend/src/lib/components/DocumentMetadataDrawer.svelte
Normal file
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
||||
import { personAvatarColor } from '$lib/utils/personFormat';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
type Tag = { id: string; name: string };
|
||||
|
||||
type Props = {
|
||||
documentDate: string | null;
|
||||
location: string | null;
|
||||
status: string;
|
||||
sender: Person | null;
|
||||
receivers: Person[];
|
||||
tags: Tag[];
|
||||
};
|
||||
|
||||
let { documentDate, location, status, sender, receivers, tags }: Props = $props();
|
||||
|
||||
const VISIBLE_RECEIVER_LIMIT = 5;
|
||||
|
||||
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
|
||||
const displayLocation = $derived(location ?? '—');
|
||||
const statusLabel = $derived(formatDocumentStatus(status));
|
||||
const visibleReceivers = $derived(receivers.slice(0, VISIBLE_RECEIVER_LIMIT));
|
||||
const hiddenReceiverCount = $derived(Math.max(0, receivers.length - VISIBLE_RECEIVER_LIMIT));
|
||||
const hasPersons = $derived(sender !== null || receivers.length > 0);
|
||||
const hasTags = $derived(tags.length > 0);
|
||||
|
||||
let showAllReceivers = $state(false);
|
||||
|
||||
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
|
||||
|
||||
function getInitials(person: Person): string {
|
||||
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
|
||||
}
|
||||
|
||||
function getFullName(person: Person): string {
|
||||
return `${person.firstName} ${person.lastName}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet personCard(person: Person)}
|
||||
<a
|
||||
href="/persons/{person.id}"
|
||||
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
|
||||
>
|
||||
<span
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
||||
style="background-color: {personAvatarColor(person.id)}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{getInitials(person)}
|
||||
</span>
|
||||
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<div class="border-b border-line p-6">
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Column 1: Details -->
|
||||
<div>
|
||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.doc_details_section_details()}
|
||||
</h2>
|
||||
<dl class="space-y-3 font-serif text-sm">
|
||||
<div>
|
||||
<dt class="font-sans text-xs font-medium text-ink-3">{m.doc_details_field_date()}</dt>
|
||||
<dd class="text-ink">{formattedDate}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-sans text-xs font-medium text-ink-3">{m.form_label_location()}</dt>
|
||||
<dd class="text-ink">{displayLocation}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-sans text-xs font-medium text-ink-3">{m.doc_details_field_status()}</dt>
|
||||
<dd class="text-ink">{statusLabel}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Column 2: Personen -->
|
||||
<div>
|
||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.doc_details_section_persons()}
|
||||
</h2>
|
||||
{#if hasPersons}
|
||||
<div class="space-y-3">
|
||||
{#if sender}
|
||||
<div>
|
||||
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
|
||||
{m.doc_details_field_sender()}
|
||||
</p>
|
||||
{@render personCard(sender)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if receivers.length > 0}
|
||||
<div>
|
||||
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
|
||||
{m.doc_details_field_receivers()}
|
||||
</p>
|
||||
<div class="space-y-0.5">
|
||||
{#each displayedReceivers as receiver (receiver.id)}
|
||||
{@render personCard(receiver)}
|
||||
{/each}
|
||||
</div>
|
||||
{#if hiddenReceiverCount > 0 && !showAllReceivers}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showAllReceivers = true)}
|
||||
class="mt-1 px-2 font-sans text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
{m.doc_details_more_receivers({ count: hiddenReceiverCount })}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_persons()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Column 3: Schlagwoerter -->
|
||||
<div>
|
||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.doc_details_section_tags()}
|
||||
</h2>
|
||||
{#if hasTags}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each tags as tag (tag.id)}
|
||||
<a
|
||||
href="/?tag={encodeURIComponent(tag.name)}"
|
||||
class="rounded bg-muted px-2 py-0.5 text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-accent"
|
||||
>
|
||||
{tag.name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller' };
|
||||
const receivers = [
|
||||
{ id: 'r1', firstName: 'Anna', lastName: 'Schmidt' },
|
||||
{ id: 'r2', firstName: 'Hans', lastName: 'Weber' }
|
||||
];
|
||||
const tags = [
|
||||
{ id: 't1', name: 'Familienbrief' },
|
||||
{ id: 't2', name: 'Kriegszeit' }
|
||||
];
|
||||
|
||||
function renderDrawer(overrides: Record<string, unknown> = {}) {
|
||||
return render(DocumentMetadataDrawer, {
|
||||
documentDate: '1942-03-15',
|
||||
location: 'Berlin',
|
||||
status: 'UPLOADED',
|
||||
sender,
|
||||
receivers,
|
||||
tags,
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Details column ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentMetadataDrawer — details column', () => {
|
||||
it('renders formatted date', async () => {
|
||||
renderDrawer();
|
||||
await expect.element(page.getByText('15. März 1942')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dash when date is null', async () => {
|
||||
renderDrawer({ documentDate: null });
|
||||
const dds = page.getByText('—');
|
||||
await expect.element(dds.first()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders location', async () => {
|
||||
renderDrawer();
|
||||
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dash when location is null', async () => {
|
||||
renderDrawer({ location: null });
|
||||
const dashes = page.getByText('—');
|
||||
await expect.element(dashes.first()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders translated status label', async () => {
|
||||
renderDrawer();
|
||||
// "Hochgeladen" is the German translation of UPLOADED
|
||||
await expect.element(page.getByText('Hochgeladen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Persons column ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentMetadataDrawer — persons column', () => {
|
||||
it('renders sender name as link to person detail', async () => {
|
||||
renderDrawer();
|
||||
const link = page.getByRole('link', { name: /Karl Müller/ });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
await expect.element(link).toHaveAttribute('href', '/persons/s1');
|
||||
});
|
||||
|
||||
it('renders receiver names as links', async () => {
|
||||
renderDrawer();
|
||||
const anna = page.getByRole('link', { name: /Anna Schmidt/ });
|
||||
await expect.element(anna).toHaveAttribute('href', '/persons/r1');
|
||||
const hans = page.getByRole('link', { name: /Hans Weber/ });
|
||||
await expect.element(hans).toHaveAttribute('href', '/persons/r2');
|
||||
});
|
||||
|
||||
it('shows empty state when no sender and no receivers', async () => {
|
||||
renderDrawer({ sender: null, receivers: [] });
|
||||
await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tags column ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentMetadataDrawer — tags column', () => {
|
||||
it('renders tag chips as links', async () => {
|
||||
renderDrawer();
|
||||
const fb = page.getByRole('link', { name: 'Familienbrief' });
|
||||
await expect.element(fb).toBeInTheDocument();
|
||||
await expect.element(fb).toHaveAttribute('href', '/?tag=Familienbrief');
|
||||
});
|
||||
|
||||
it('shows empty state when no tags', async () => {
|
||||
renderDrawer({ tags: [] });
|
||||
await expect.element(page.getByText('Keine Schlagwörter zugeordnet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/components/DocumentStatusChip.svelte
Normal file
20
frontend/src/lib/components/DocumentStatusChip.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { statusDotClass, statusLabel } from '$lib/utils/personFormat';
|
||||
|
||||
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
|
||||
|
||||
type Props = {
|
||||
status: DocumentStatus;
|
||||
};
|
||||
|
||||
let { status }: Props = $props();
|
||||
|
||||
const dotClass = $derived(statusDotClass(status));
|
||||
const label = $derived(statusLabel(status));
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="hidden shrink-0 md:block {dotClass} h-4 w-4 rounded-full"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
></span>
|
||||
@@ -1,7 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { formatDate } from '$lib/utils/personFormat';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import PersonChipRow from './PersonChipRow.svelte';
|
||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
type Tag = { id: string; name: string };
|
||||
|
||||
type Doc = {
|
||||
id: string;
|
||||
@@ -12,141 +19,270 @@ type Doc = {
|
||||
receivers?: Person[] | null;
|
||||
filePath?: string | null;
|
||||
contentType?: string | null;
|
||||
location?: string | null;
|
||||
status?: string | null;
|
||||
tags?: Tag[] | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
canWrite: boolean;
|
||||
canAnnotate: boolean;
|
||||
fileUrl: string;
|
||||
annotateMode: boolean;
|
||||
transcribeMode: boolean;
|
||||
};
|
||||
|
||||
let { doc, canWrite, canAnnotate, fileUrl, annotateMode = $bindable() }: Props = $props();
|
||||
let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props();
|
||||
|
||||
let detailsOpen = $state(false);
|
||||
|
||||
const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('application/pdf'));
|
||||
const receivers = $derived(doc.receivers ?? []);
|
||||
const extraCount = $derived(Math.max(0, receivers.length - 2));
|
||||
const overflowPersons = $derived(receivers.slice(2));
|
||||
|
||||
const receiverDisplay = $derived.by(() => {
|
||||
const receivers = doc.receivers ?? [];
|
||||
if (receivers.length === 0) return null;
|
||||
const shown = receivers.slice(0, 2);
|
||||
const extra = receivers.length - shown.length;
|
||||
const names = shown.map((r) => `${r.firstName} ${r.lastName}`).join(', ');
|
||||
return extra > 0 ? `${names} +${extra}` : names;
|
||||
});
|
||||
const shortDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
|
||||
const longDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'long') : null);
|
||||
|
||||
const compactMeta = $derived.by(() => {
|
||||
const parts: string[] = [];
|
||||
if (doc.documentDate) {
|
||||
parts.push(
|
||||
new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric'
|
||||
}).format(new Date(doc.documentDate + 'T12:00:00'))
|
||||
);
|
||||
}
|
||||
if (doc.sender) {
|
||||
const senderName = `${doc.sender.firstName} ${doc.sender.lastName}`;
|
||||
const receiver = receiverDisplay;
|
||||
parts.push(receiver ? `${senderName} → ${receiver}` : senderName);
|
||||
} else if (receiverDisplay) {
|
||||
parts.push(`→ ${receiverDisplay}`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
});
|
||||
let mobileMenuOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="z-20 flex shrink-0 items-center justify-between border-b border-line bg-surface px-3 py-3 shadow-sm sm:px-6"
|
||||
data-topbar
|
||||
>
|
||||
<!-- Left: back + title -->
|
||||
<div class="flex min-w-0 items-center gap-4 overflow-hidden">
|
||||
{#snippet transcribeBtn(mobile: boolean)}
|
||||
<button
|
||||
onclick={() => {
|
||||
transcribeMode = true;
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
aria-label={m.transcription_mode_label()}
|
||||
aria-pressed={false}
|
||||
class={mobile
|
||||
? 'flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
|
||||
: 'hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex'}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
{m.transcription_mode_label()}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet transcribeStopBtn(mobile: boolean)}
|
||||
<button
|
||||
onclick={() => {
|
||||
transcribeMode = false;
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
aria-label={m.transcription_mode_stop()}
|
||||
aria-pressed={true}
|
||||
class={mobile
|
||||
? 'flex w-full items-center gap-2 rounded bg-primary px-3 py-2 text-left text-[16px] text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'
|
||||
: 'flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 font-sans text-[16px] font-medium text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
{m.transcription_mode_stop()}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet downloadLink(mobile: boolean)}
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={doc.originalFilename}
|
||||
onclick={() => {
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
class={mobile
|
||||
? 'flex items-center gap-2 rounded px-3 py-2 text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
|
||||
: 'hidden rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary md:block'}
|
||||
title={m.doc_download_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0"
|
||||
/>
|
||||
{#if mobile}{m.doc_download_title()}{/if}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<div data-topbar class="relative z-10 border-b border-line bg-surface shadow-sm">
|
||||
<!-- Main row -->
|
||||
<div class="flex h-[75px] shrink-0 items-center xs:h-[88px]">
|
||||
<!-- Accent bar -->
|
||||
<div class="h-full w-[3px] shrink-0 bg-primary"></div>
|
||||
|
||||
<!-- Back link — 44×44px touch target -->
|
||||
<a
|
||||
href="/"
|
||||
class="group flex shrink-0 items-center gap-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
aria-label={m.topbar_back_label()}
|
||||
class="group -ml-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-canvas transition-colors group-hover:bg-accent"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<span class="hidden sm:inline">{m.btn_back()}</span>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div class="min-w-0 border-l border-line pl-4">
|
||||
<!-- Divider -->
|
||||
<div class="mx-2 h-6 w-px shrink-0 bg-line"></div>
|
||||
|
||||
<!-- Title + meta -->
|
||||
<div class="min-w-0 flex-1 overflow-hidden">
|
||||
<h1
|
||||
class="truncate font-serif text-base leading-tight text-ink"
|
||||
class="truncate font-serif text-[18px] leading-tight text-ink lg:text-[20px]"
|
||||
title={doc.title ?? doc.originalFilename ?? ''}
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</h1>
|
||||
{#if compactMeta}
|
||||
<p class="truncate font-sans text-xs text-ink-2" title={compactMeta}>
|
||||
{compactMeta}
|
||||
{#if shortDate}
|
||||
<p class="font-sans text-[16px] text-ink-2">
|
||||
<span class="lg:hidden">{shortDate}</span>
|
||||
<span class="hidden lg:inline">{longDate}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chip row — desktop only, hidden on small screens to make room for buttons -->
|
||||
<div class="mx-3 hidden min-w-0 shrink-0 md:block">
|
||||
<PersonChipRow sender={doc.sender} receivers={receivers} abbreviated={true} extraCount={0} />
|
||||
</div>
|
||||
|
||||
<!-- Overflow pill button (desktop) + status dot -->
|
||||
{#if extraCount > 0}
|
||||
<OverflowPillButton extraCount={extraCount} persons={overflowPersons} />
|
||||
{/if}
|
||||
|
||||
<!-- Details toggle -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (detailsOpen = !detailsOpen)}
|
||||
aria-expanded={detailsOpen}
|
||||
aria-label={m.doc_details_toggle()}
|
||||
class="ml-2 inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded border px-3 py-1 font-sans text-sm font-semibold transition-colors {detailsOpen ? 'border-primary bg-primary text-primary-fg' : 'border-line text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.doc_details_toggle()}
|
||||
<svg
|
||||
class="h-3.5 w-3.5 transition-transform duration-200 {detailsOpen ? 'rotate-180' : ''}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Divider between metadata and actions -->
|
||||
<div class="mx-3 hidden h-6 w-px shrink-0 bg-line md:block"></div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex shrink-0 items-center gap-1.5 pr-3 font-sans">
|
||||
{#if canWrite && isPdf && !transcribeMode}
|
||||
{@render transcribeBtn(false)}
|
||||
{/if}
|
||||
|
||||
{#if transcribeMode}
|
||||
{@render transcribeStopBtn(false)}
|
||||
{/if}
|
||||
|
||||
{#if canWrite && !transcribeMode}
|
||||
<a
|
||||
href="/documents/{doc.id}/edit"
|
||||
aria-label={m.btn_edit()}
|
||||
class="flex items-center gap-1.5 rounded border border-primary bg-transparent px-3 py-1.5 text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
<span class="hidden sm:inline">{m.btn_edit()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath && !transcribeMode}
|
||||
{@render downloadLink(false)}
|
||||
{/if}
|
||||
|
||||
<!-- Kebab menu — mobile only, contains actions hidden below md -->
|
||||
{#if (canWrite && isPdf) || doc.filePath}
|
||||
<div
|
||||
role="group"
|
||||
class="relative md:hidden"
|
||||
use:clickOutside
|
||||
onclickoutside={() => (mobileMenuOpen = false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
aria-label={m.topbar_more_actions()}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
class="flex h-9 w-9 items-center justify-center rounded border border-line bg-muted transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/View-More-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if mobileMenuOpen}
|
||||
<div
|
||||
role="menu"
|
||||
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
|
||||
>
|
||||
{#if canWrite && isPdf && !transcribeMode}
|
||||
{@render transcribeBtn(true)}
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath}
|
||||
{@render downloadLink(true)}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="ml-4 flex shrink-0 items-center gap-2 font-sans">
|
||||
{#if canAnnotate && isPdf}
|
||||
<button
|
||||
onclick={() => (annotateMode = !annotateMode)}
|
||||
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
||||
class="flex items-center gap-1.5 rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'border border-primary text-ink hover:bg-primary hover:text-primary-fg'}"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 {annotateMode ? 'invert' : ''}"
|
||||
/>
|
||||
<span class="hidden sm:inline"
|
||||
>{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}</span
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if canWrite}
|
||||
<a
|
||||
href="/documents/{doc.id}/edit"
|
||||
aria-label={m.btn_edit()}
|
||||
class="flex items-center gap-2 rounded border border-primary bg-transparent px-3 py-1.5 text-xs font-medium text-ink transition hover:bg-primary hover:text-primary-fg"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="hidden sm:inline">{m.btn_edit()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath}
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={doc.originalFilename}
|
||||
class="rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent"
|
||||
title={m.doc_download_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Metadata drawer -->
|
||||
{#if detailsOpen}
|
||||
<div transition:slide={{ duration: 200 }}>
|
||||
<DocumentMetadataDrawer
|
||||
documentDate={doc.documentDate ?? null}
|
||||
location={doc.location ?? null}
|
||||
status={doc.status ?? 'PLACEHOLDER'}
|
||||
sender={doc.sender ?? null}
|
||||
receivers={doc.receivers ? [...doc.receivers] : []}
|
||||
tags={doc.tags ? [...doc.tags] : []}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -9,15 +9,18 @@ type Doc = {
|
||||
fileHash?: string | null;
|
||||
};
|
||||
|
||||
type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number };
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
fileUrl: string;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
annotateMode: boolean;
|
||||
transcribeMode?: boolean;
|
||||
blockNumbers?: Record<string, number>;
|
||||
activeAnnotationId: string | null;
|
||||
activeAnnotationPage: number | null;
|
||||
onAnnotationClick: (id: string) => void;
|
||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -25,10 +28,11 @@ let {
|
||||
fileUrl,
|
||||
isLoading,
|
||||
error,
|
||||
annotateMode = $bindable(),
|
||||
transcribeMode = false,
|
||||
blockNumbers = {},
|
||||
activeAnnotationId = $bindable(),
|
||||
activeAnnotationPage = $bindable(),
|
||||
onAnnotationClick
|
||||
onAnnotationClick,
|
||||
onTranscriptionDraw
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -80,10 +84,11 @@ let {
|
||||
<PdfViewer
|
||||
url={fileUrl}
|
||||
documentId={doc.id}
|
||||
bind:annotateMode={annotateMode}
|
||||
transcribeMode={transcribeMode}
|
||||
blockNumbers={blockNumbers}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
bind:activeAnnotationPage={activeAnnotationPage}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
onTranscriptionDraw={onTranscriptionDraw}
|
||||
documentFileHash={doc.fileHash ?? null}
|
||||
/>
|
||||
{:else if fileUrl}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||
|
||||
let { inverted = false }: { inverted?: boolean } = $props();
|
||||
|
||||
const locales = ['DE', 'EN', 'ES'] as const;
|
||||
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
|
||||
const activeLocale = $derived(getLocale().toUpperCase());
|
||||
@@ -10,8 +12,14 @@ const activeLocale = $derived(getLocale().toUpperCase());
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLocale(localeMap[locale])}
|
||||
class="font-sans tracking-widest transition-colors
|
||||
{activeLocale === locale ? 'font-bold text-ink' : 'font-normal text-ink-3 hover:text-ink'}"
|
||||
class="rounded px-1 font-sans tracking-widest transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{activeLocale === locale
|
||||
? inverted
|
||||
? 'font-bold text-white'
|
||||
: 'font-bold text-ink'
|
||||
: inverted
|
||||
? 'font-normal text-white/70 hover:text-white'
|
||||
: 'font-normal text-ink-3 hover:text-ink'}"
|
||||
>
|
||||
{locale}
|
||||
</button>
|
||||
|
||||
94
frontend/src/lib/components/LanguageSwitcher.svelte.spec.ts
Normal file
94
frontend/src/lib/components/LanguageSwitcher.svelte.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import LanguageSwitcher from './LanguageSwitcher.svelte';
|
||||
|
||||
const mockSetLocale = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('$lib/paraglide/runtime', () => ({
|
||||
getLocale: vi.fn(() => 'de'),
|
||||
setLocale: mockSetLocale
|
||||
}));
|
||||
|
||||
beforeEach(() => mockSetLocale.mockClear());
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── inverted=true (dark background) ──────────────────────────────────────
|
||||
|
||||
describe('LanguageSwitcher – inverted=true', () => {
|
||||
it('active locale button has text-white and font-bold', async () => {
|
||||
render(LanguageSwitcher, { inverted: true });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'DE' }).element();
|
||||
|
||||
expect(el.className).toMatch(/\btext-white\b/);
|
||||
expect(el.className).toMatch(/\bfont-bold\b/);
|
||||
});
|
||||
|
||||
it('inactive locale buttons have text-white/70', async () => {
|
||||
render(LanguageSwitcher, { inverted: true });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'EN' }).element();
|
||||
|
||||
expect(el.className).toMatch(/text-white\/70/);
|
||||
});
|
||||
|
||||
it('inactive locale buttons do not have font-bold', async () => {
|
||||
render(LanguageSwitcher, { inverted: true });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'EN' }).element();
|
||||
|
||||
expect(el.className).not.toMatch(/\bfont-bold\b/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── inverted=false (light background) ─────────────────────────────────────
|
||||
|
||||
describe('LanguageSwitcher – inverted=false', () => {
|
||||
it('active locale button has text-ink and font-bold', async () => {
|
||||
render(LanguageSwitcher, { inverted: false });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'DE' }).element();
|
||||
|
||||
expect(el.className).toMatch(/\btext-ink\b/);
|
||||
expect(el.className).toMatch(/\bfont-bold\b/);
|
||||
});
|
||||
|
||||
it('inactive locale buttons have text-ink-3', async () => {
|
||||
render(LanguageSwitcher, { inverted: false });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'EN' }).element();
|
||||
|
||||
expect(el.className).toMatch(/\btext-ink-3\b/);
|
||||
});
|
||||
|
||||
it('inactive locale buttons do not have text-white', async () => {
|
||||
render(LanguageSwitcher, { inverted: false });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'EN' }).element();
|
||||
|
||||
expect(el.className).not.toMatch(/\btext-white\b/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── locale switching ──────────────────────────────────────────────────────
|
||||
|
||||
describe('LanguageSwitcher – locale switching', () => {
|
||||
it('calls setLocale with en when EN button is clicked', async () => {
|
||||
render(LanguageSwitcher, { inverted: false });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'EN' }).element();
|
||||
el.click();
|
||||
|
||||
expect(mockSetLocale).toHaveBeenCalledWith('en');
|
||||
});
|
||||
|
||||
it('calls setLocale with es when ES button is clicked', async () => {
|
||||
render(LanguageSwitcher, { inverted: false });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'ES' }).element();
|
||||
el.click();
|
||||
|
||||
expect(mockSetLocale).toHaveBeenCalledWith('es');
|
||||
});
|
||||
});
|
||||
@@ -115,7 +115,8 @@ function closePopup() {
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
// Enter sends, Shift+Enter adds newline
|
||||
if (e.key === 'Enter' && !e.shiftKey && query === null) {
|
||||
e.preventDefault();
|
||||
onsubmit?.();
|
||||
return;
|
||||
@@ -152,33 +153,6 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAtButtonClick() {
|
||||
if (!textarea) return;
|
||||
const pos = textarea.selectionStart;
|
||||
const before = value.slice(0, pos);
|
||||
const after = value.slice(pos);
|
||||
// Ensure @ is preceded by whitespace or is at the start
|
||||
const needsSpace = before.length > 0 && !/\s$/.test(before);
|
||||
const insertion = needsSpace ? ' @' : '@';
|
||||
value = before + insertion + after;
|
||||
|
||||
await tick();
|
||||
if (!textarea) return;
|
||||
const newPos = pos + insertion.length;
|
||||
textarea.selectionStart = newPos;
|
||||
textarea.selectionEnd = newPos;
|
||||
textarea.focus();
|
||||
|
||||
// Trigger mention detection after inserting @
|
||||
const detected = detectMention(value, newPos);
|
||||
if (detected !== null) {
|
||||
mentionStart = newPos - 1;
|
||||
query = detected;
|
||||
highlightedIndex = 0;
|
||||
scheduleSearch(detected);
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => clearTimeout(debounceTimer));
|
||||
|
||||
const popupOpen = $derived(query !== null);
|
||||
@@ -187,7 +161,7 @@ const popupOpen = $derived(query !== null);
|
||||
<div class="relative">
|
||||
<textarea
|
||||
{@attach attachTextarea}
|
||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
@@ -224,14 +198,4 @@ const popupOpen = $derived(query !== null);
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.mention_btn_label()}
|
||||
disabled={disabled}
|
||||
class="mt-1 rounded border border-line px-2 py-0.5 font-sans text-xs font-medium text-ink-3 transition-colors hover:border-ink hover:text-ink disabled:opacity-40"
|
||||
onclick={handleAtButtonClick}
|
||||
>
|
||||
@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@ onDestroy(() => {
|
||||
: m.notification_bell_label()}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
class="relative rounded-sm p-2 text-ink-2 transition-colors hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<!-- Bell SVG -->
|
||||
<svg
|
||||
|
||||
77
frontend/src/lib/components/OverflowPillButton.svelte
Normal file
77
frontend/src/lib/components/OverflowPillButton.svelte
Normal file
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
|
||||
type Props = {
|
||||
extraCount: number;
|
||||
persons: Person[];
|
||||
};
|
||||
|
||||
let { extraCount, persons }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let buttonEl: HTMLButtonElement | undefined = $state();
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
async function close() {
|
||||
open = false;
|
||||
await tick();
|
||||
buttonEl?.focus();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="group"
|
||||
class="relative hidden md:block"
|
||||
use:clickOutside
|
||||
onclickoutside={() => (open = false)}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<button
|
||||
bind:this={buttonEl}
|
||||
type="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-label={m.topbar_overflow_show({ count: extraCount })}
|
||||
onclick={toggle}
|
||||
onkeydown={handleKeydown}
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted px-2 py-0.5 text-[14px] font-bold text-ink-2 hover:bg-surface focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
+{extraCount}<span class="hidden lg:inline"> {m.topbar_overflow_suffix()}</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
role="list"
|
||||
class="absolute top-full left-0 z-50 mt-1 min-w-[160px] rounded-md border border-line bg-surface p-3 shadow-lg"
|
||||
>
|
||||
<p class="mb-2 text-[14px] font-bold tracking-wide text-ink-2 uppercase">
|
||||
{m.topbar_overflow_heading()}
|
||||
</p>
|
||||
{#each persons as person (person.id)}
|
||||
<div role="listitem">
|
||||
<a
|
||||
href="/persons/{person.id}"
|
||||
class="block py-0.5 text-[18px] text-ink hover:text-primary focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const persons = [
|
||||
{ id: 'p1', firstName: 'Anna', lastName: 'Müller' },
|
||||
{ id: 'p2', firstName: 'Hans', lastName: 'Schmidt' }
|
||||
];
|
||||
|
||||
describe('OverflowPillButton', () => {
|
||||
it('renders button with correct aria-haspopup and collapsed aria-expanded', async () => {
|
||||
render(OverflowPillButton, { extraCount: 2, persons });
|
||||
const btn = page.getByRole('button');
|
||||
await expect.element(btn).toHaveAttribute('aria-haspopup', 'true');
|
||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('shows tooltip on click and sets aria-expanded true', async () => {
|
||||
render(OverflowPillButton, { extraCount: 2, persons });
|
||||
const btn = page.getByRole('button');
|
||||
await userEvent.click(btn);
|
||||
const tooltip = page.getByRole('list');
|
||||
await expect.element(tooltip).toBeInTheDocument();
|
||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('closes tooltip on Escape and returns focus to button', async () => {
|
||||
render(OverflowPillButton, { extraCount: 2, persons });
|
||||
const btn = page.getByRole('button');
|
||||
await userEvent.click(btn);
|
||||
await expect.element(page.getByRole('list')).toBeInTheDocument();
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await expect.element(page.getByRole('list')).not.toBeInTheDocument();
|
||||
await expect.element(btn).toHaveFocus();
|
||||
});
|
||||
|
||||
it('renders person links inside tooltip', async () => {
|
||||
render(OverflowPillButton, { extraCount: 2, persons });
|
||||
await userEvent.click(page.getByRole('button'));
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links.nth(0)).toHaveAttribute('href', '/persons/p1');
|
||||
await expect.element(links.nth(1)).toHaveAttribute('href', '/persons/p2');
|
||||
});
|
||||
});
|
||||
14
frontend/src/lib/components/OverflowPillDisplay.svelte
Normal file
14
frontend/src/lib/components/OverflowPillDisplay.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
extraCount: number;
|
||||
};
|
||||
|
||||
let { extraCount }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted px-2 py-0.5 text-[14px] font-bold text-ink-2"
|
||||
>
|
||||
+{extraCount}
|
||||
</span>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
import type { Comment } from '$lib/types';
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
initialComments: Comment[];
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
targetCommentId?: string | null;
|
||||
onCountChange?: (count: number) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
initialComments,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
targetCommentId = null,
|
||||
onCountChange
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
initialComments={initialComments}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
targetCommentId={targetCommentId}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,519 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { diffWords } from 'diff';
|
||||
|
||||
let { documentId }: { documentId: string } = $props();
|
||||
|
||||
type VersionSummary = {
|
||||
id: string;
|
||||
savedAt: string;
|
||||
editorName: string;
|
||||
changedFields: string[];
|
||||
};
|
||||
|
||||
type SnapshotDoc = {
|
||||
title?: string;
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
documentLocation?: string;
|
||||
transcription?: string;
|
||||
summary?: string;
|
||||
sender?: { id: string; firstName: string; lastName: string } | null;
|
||||
receivers?: { id: string; firstName: string; lastName: string }[];
|
||||
tags?: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
type DiffEntry =
|
||||
| {
|
||||
kind: 'text';
|
||||
field: string;
|
||||
label: string;
|
||||
parts: { value: string; added?: boolean; removed?: boolean }[];
|
||||
}
|
||||
| { kind: 'scalar'; field: string; label: string; oldVal: string; newVal: string }
|
||||
| { kind: 'relation'; field: string; label: string; removed: string[]; added: string[] };
|
||||
|
||||
let historyLoaded = $state(false);
|
||||
let historyLoading = $state(false);
|
||||
let versions = $state<VersionSummary[]>([]);
|
||||
|
||||
let compareMode = $state(false);
|
||||
let compareA = $state('');
|
||||
let compareB = $state('');
|
||||
|
||||
let selectedVersionId = $state<string | null>(null);
|
||||
let diffEntries = $state<DiffEntry[]>([]);
|
||||
let diffLoading = $state(false);
|
||||
let noDiff = $state(false);
|
||||
|
||||
const fieldLabels: Record<string, () => string> = {
|
||||
title: m.history_field_title,
|
||||
documentDate: m.history_field_document_date,
|
||||
location: m.history_field_location,
|
||||
documentLocation: m.history_field_document_location,
|
||||
transcription: m.history_field_transcription,
|
||||
summary: m.history_field_summary,
|
||||
sender: m.history_field_sender,
|
||||
receivers: m.history_field_receivers,
|
||||
tags: m.history_field_tags
|
||||
};
|
||||
|
||||
const TEXT_FIELDS = ['title', 'summary', 'transcription'] as const;
|
||||
const SCALAR_FIELDS = ['documentDate', 'location', 'documentLocation'] as const;
|
||||
|
||||
function parseSnapshot(raw: string): SnapshotDoc {
|
||||
try {
|
||||
return JSON.parse(raw) as SnapshotDoc;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function personLabel(p: { firstName: string; lastName: string }): string {
|
||||
return `${p.firstName} ${p.lastName}`.trim();
|
||||
}
|
||||
|
||||
const DIFF_CONTEXT_WORDS = 4;
|
||||
|
||||
type DiffPart = { value: string; added?: boolean; removed?: boolean };
|
||||
|
||||
function trimContextParts(parts: DiffPart[]): DiffPart[] {
|
||||
return parts.flatMap((part, i) => {
|
||||
if (part.added || part.removed) return [part];
|
||||
const tokens = part.value.split(/(\s+)/).filter(Boolean);
|
||||
const wordCount = tokens.filter((t) => /\S/.test(t)).length;
|
||||
if (wordCount <= DIFF_CONTEXT_WORDS * 2) return [part];
|
||||
|
||||
function keepFirst(n: number): string {
|
||||
let count = 0;
|
||||
const out: string[] = [];
|
||||
for (const t of tokens) {
|
||||
out.push(t);
|
||||
if (/\S/.test(t) && ++count >= n) break;
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
function keepLast(n: number): string {
|
||||
let count = 0;
|
||||
const out: string[] = [];
|
||||
for (const t of [...tokens].reverse()) {
|
||||
out.unshift(t);
|
||||
if (/\S/.test(t) && ++count >= n) break;
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === parts.length - 1;
|
||||
if (isFirst) return [{ value: '… ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
||||
if (isLast) return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' …' }];
|
||||
return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' … ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
||||
});
|
||||
}
|
||||
|
||||
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
||||
const entries: DiffEntry[] = [];
|
||||
|
||||
for (const field of TEXT_FIELDS) {
|
||||
const a = older?.[field] ?? '';
|
||||
const b = newer[field] ?? '';
|
||||
if (a === b) continue;
|
||||
const parts = trimContextParts(diffWords(a, b));
|
||||
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
|
||||
}
|
||||
|
||||
for (const field of SCALAR_FIELDS) {
|
||||
const a = older?.[field] ?? '';
|
||||
const b = newer[field] ?? '';
|
||||
if (a === b) continue;
|
||||
entries.push({ kind: 'scalar', field, label: fieldLabels[field](), oldVal: a, newVal: b });
|
||||
}
|
||||
|
||||
const senderA = older?.sender ? personLabel(older.sender) : '';
|
||||
const senderB = newer.sender ? personLabel(newer.sender) : '';
|
||||
if (senderA !== senderB) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'sender',
|
||||
label: fieldLabels['sender'](),
|
||||
removed: senderA ? [senderA] : [],
|
||||
added: senderB ? [senderB] : []
|
||||
});
|
||||
}
|
||||
|
||||
const receiversA = new Set((older?.receivers ?? []).map(personLabel));
|
||||
const receiversB = new Set((newer.receivers ?? []).map(personLabel));
|
||||
const removedReceivers = [...receiversA].filter((r) => !receiversB.has(r));
|
||||
const addedReceivers = [...receiversB].filter((r) => !receiversA.has(r));
|
||||
if (removedReceivers.length > 0 || addedReceivers.length > 0) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'receivers',
|
||||
label: fieldLabels['receivers'](),
|
||||
removed: removedReceivers,
|
||||
added: addedReceivers
|
||||
});
|
||||
}
|
||||
|
||||
const tagsA = new Set((older?.tags ?? []).map((t) => t.name));
|
||||
const tagsB = new Set((newer.tags ?? []).map((t) => t.name));
|
||||
const removedTags = [...tagsA].filter((t) => !tagsB.has(t));
|
||||
const addedTags = [...tagsB].filter((t) => !tagsA.has(t));
|
||||
if (removedTags.length > 0 || addedTags.length > 0) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'tags',
|
||||
label: fieldLabels['tags'](),
|
||||
removed: removedTags,
|
||||
added: addedTags
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function fetchSnapshot(versionId: string): Promise<SnapshotDoc> {
|
||||
const res = await fetch(`/api/documents/${documentId}/versions/${versionId}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch version');
|
||||
const v = await res.json();
|
||||
return parseSnapshot(v.snapshot);
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
if (historyLoaded) return;
|
||||
historyLoading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/versions`);
|
||||
if (res.ok) {
|
||||
versions = await res.json();
|
||||
}
|
||||
historyLoaded = true;
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
historyLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVersion(versionId: string) {
|
||||
if (selectedVersionId === versionId) {
|
||||
selectedVersionId = null;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
return;
|
||||
}
|
||||
selectedVersionId = versionId;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
diffLoading = true;
|
||||
try {
|
||||
const idx = versions.findIndex((v) => v.id === versionId);
|
||||
const newerSnap = await fetchSnapshot(versionId);
|
||||
const olderSnap = idx > 0 ? await fetchSnapshot(versions[idx - 1].id) : null;
|
||||
const entries = buildDiff(olderSnap, newerSnap);
|
||||
if (entries.length === 0) {
|
||||
noDiff = true;
|
||||
} else {
|
||||
diffEntries = entries;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
diffLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCompare() {
|
||||
if (!compareA || !compareB || compareA === compareB) return;
|
||||
selectedVersionId = null;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
diffLoading = true;
|
||||
try {
|
||||
const [snapA, snapB] = await Promise.all([fetchSnapshot(compareA), fetchSnapshot(compareB)]);
|
||||
const entries = buildDiff(snapA, snapB);
|
||||
if (entries.length === 0) {
|
||||
noDiff = true;
|
||||
} else {
|
||||
diffEntries = entries;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
diffLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function versionLabel(v: VersionSummary, index: number): string {
|
||||
return `Version ${index + 1} — ${v.editorName} — ${formatDateTime(v.savedAt)}`;
|
||||
}
|
||||
|
||||
// Load history when this panel mounts.
|
||||
$effect(() => {
|
||||
loadHistory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 p-6">
|
||||
{#if historyLoading}
|
||||
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
|
||||
{:else if !historyLoaded}
|
||||
<!-- initial state before effect runs — show nothing -->
|
||||
{:else if versions.length === 0}
|
||||
<p class="font-serif text-sm text-ink-3 italic">{m.history_empty()}</p>
|
||||
{:else}
|
||||
<!-- Compare mode toggle -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
onclick={() => {
|
||||
compareMode = !compareMode;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
selectedVersionId = null;
|
||||
}}
|
||||
class="font-sans text-xs font-medium transition {compareMode
|
||||
? 'text-ink underline'
|
||||
: 'text-ink-3 hover:text-ink'}"
|
||||
>
|
||||
{m.history_compare_mode()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if compareMode}
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label for="compare-a" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
|
||||
>{m.history_compare_select_a()}</label
|
||||
>
|
||||
<select
|
||||
id="compare-a"
|
||||
bind:value={compareA}
|
||||
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each versions as v, i (v.id)}
|
||||
<option value={v.id}>{versionLabel(v, i)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="compare-b" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
|
||||
>{m.history_compare_select_b()}</label
|
||||
>
|
||||
<select
|
||||
id="compare-b"
|
||||
bind:value={compareB}
|
||||
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each versions as v, i (v.id)}
|
||||
<option value={v.id}>{versionLabel(v, i)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onclick={applyCompare}
|
||||
disabled={!compareA || !compareB || compareA === compareB}
|
||||
class="w-full rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{m.history_compare_apply()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Diff panel for compare mode -->
|
||||
{#if diffLoading}
|
||||
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
|
||||
{:else if noDiff}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 italic"
|
||||
>
|
||||
{m.history_diff_no_changes()}
|
||||
</div>
|
||||
{:else if diffEntries.length > 0}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="space-y-4 rounded-sm border border-line bg-surface p-4"
|
||||
>
|
||||
{#each diffEntries as entry (entry.field)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 uppercase"
|
||||
>{entry.label}</span
|
||||
>
|
||||
{#if entry.kind === 'text'}
|
||||
<p class="font-serif text-sm leading-relaxed">
|
||||
{#each entry.parts as part, partIdx (partIdx)}
|
||||
{#if part.added}
|
||||
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||
{:else if part.removed}
|
||||
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
|
||||
{:else}
|
||||
<span>{part.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{:else if entry.kind === 'scalar'}
|
||||
<div class="flex items-center gap-2 font-serif text-sm">
|
||||
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||
<svg
|
||||
class="h-3 w-3 flex-shrink-0 text-ink-3"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||
</div>
|
||||
{:else if entry.kind === 'relation'}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each entry.removed as item (item)}
|
||||
<span
|
||||
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
{#each entry.added as item (item)}
|
||||
<span
|
||||
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Version list with inline diff below each selected item -->
|
||||
<ul class="divide-y divide-line">
|
||||
{#each versions as v, i (v.id)}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => selectVersion(v.id)}
|
||||
data-testid="history-version"
|
||||
class="w-full py-2 text-left transition hover:bg-muted {selectedVersionId ===
|
||||
v.id
|
||||
? 'border-l-2 border-accent pl-2'
|
||||
: 'pl-0'}"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="font-sans text-xs font-medium text-ink">
|
||||
Version {i + 1}
|
||||
</span>
|
||||
<span class="font-sans text-[10px] text-ink-3">
|
||||
{formatDateTime(v.savedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-sans text-[11px] text-ink-2">{v.editorName}</span>
|
||||
{#if v.changedFields && v.changedFields.length > 0}
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each v.changedFields as field (field)}
|
||||
<span
|
||||
class="rounded bg-muted px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-ink-2 uppercase"
|
||||
>
|
||||
{fieldLabels[field] ? fieldLabels[field]() : field}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Diff shown inline below the selected version -->
|
||||
{#if selectedVersionId === v.id}
|
||||
{#if diffLoading}
|
||||
<p class="pb-3 pl-2 font-sans text-xs text-ink-3">{m.history_loading()}</p>
|
||||
{:else if noDiff}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="mb-2 rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 italic"
|
||||
>
|
||||
{m.history_diff_no_changes()}
|
||||
</div>
|
||||
{:else if diffEntries.length > 0}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="mb-2 space-y-4 rounded-sm border border-line bg-surface p-4"
|
||||
>
|
||||
{#each diffEntries as entry (entry.field)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 uppercase"
|
||||
>{entry.label}</span
|
||||
>
|
||||
{#if entry.kind === 'text'}
|
||||
<p class="font-serif text-sm leading-relaxed">
|
||||
{#each entry.parts as part, partIdx (partIdx)}
|
||||
{#if part.added}
|
||||
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||
{:else if part.removed}
|
||||
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
|
||||
{:else}
|
||||
<span>{part.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{:else if entry.kind === 'scalar'}
|
||||
<div class="flex items-center gap-2 font-serif text-sm">
|
||||
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||
<svg
|
||||
class="h-3 w-3 flex-shrink-0 text-ink-3"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||
</div>
|
||||
{:else if entry.kind === 'relation'}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each entry.removed as item (item)}
|
||||
<span
|
||||
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
{#each entry.added as item (item)}
|
||||
<span
|
||||
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,198 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string; alias?: string | null };
|
||||
type Tag = { id: string; name: string };
|
||||
|
||||
type Doc = {
|
||||
documentDate?: string | null;
|
||||
location?: string | null;
|
||||
documentLocation?: string | null;
|
||||
tags?: Tag[] | null;
|
||||
sender?: Person | null;
|
||||
receivers?: Person[] | null;
|
||||
};
|
||||
|
||||
let { doc }: { doc: Doc } = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-10 p-6">
|
||||
<!-- DETAILS GROUP -->
|
||||
<div>
|
||||
<h3
|
||||
class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||
>
|
||||
{m.doc_section_details()}
|
||||
</h3>
|
||||
<div class="space-y-5">
|
||||
<!-- Date -->
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-accent">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-ink">
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-ink-2">{m.doc_label_document_date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creation Location -->
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-accent">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-ink">
|
||||
{doc.location ? doc.location : '—'}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-ink-2">{m.doc_label_creation_location()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Physical Archive Location -->
|
||||
{#if doc.documentLocation}
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-accent">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-ink">
|
||||
{doc.documentLocation}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-ink-2"
|
||||
>{m.doc_label_archive_location_original()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tags -->
|
||||
{#if doc.tags && doc.tags.length > 0}
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-accent">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1 flex flex-wrap gap-2">
|
||||
{#each doc.tags as tag (tag.id)}
|
||||
<a
|
||||
href="/?tag={encodeURIComponent(tag.name)}"
|
||||
class="inline-flex items-center rounded bg-muted px-2 py-0.5 text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
|
||||
title={m.doc_tag_filter_title({ name: tag.name })}
|
||||
>
|
||||
{tag.name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="font-sans text-xs text-ink-2">{m.form_label_tags()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PERSONEN GROUP -->
|
||||
<div>
|
||||
<h3
|
||||
class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||
>
|
||||
{m.doc_section_persons()}
|
||||
</h3>
|
||||
|
||||
<div class="mb-6">
|
||||
<span class="mb-2 block font-sans text-xs text-ink-3 uppercase">{m.form_label_sender()}</span>
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/persons/{doc.sender.id}"
|
||||
class="group block rounded border border-line bg-muted p-3 transition hover:border-accent hover:bg-accent/10"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-serif text-sm text-primary-fg"
|
||||
>
|
||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-serif text-ink group-hover:underline">
|
||||
{doc.sender.firstName}
|
||||
{doc.sender.lastName}
|
||||
</p>
|
||||
{#if doc.sender.alias}
|
||||
<p class="font-sans text-xs text-ink-2">{doc.sender.alias}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="font-serif text-sm text-ink-3 italic">{m.doc_sender_not_specified()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-2 block font-sans text-xs text-ink-3 uppercase"
|
||||
>{m.form_label_receivers()}</span
|
||||
>
|
||||
{#if doc.receivers && doc.receivers.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each doc.receivers as receiver (receiver.id)}
|
||||
<div
|
||||
class="group flex items-center justify-between rounded border border-line bg-surface p-3 transition hover:border-primary"
|
||||
>
|
||||
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-muted font-serif text-xs text-ink-2"
|
||||
>
|
||||
{receiver.firstName[0]}{receiver.lastName[0]}
|
||||
</div>
|
||||
<span class="truncate font-serif text-sm text-ink">
|
||||
{receiver.firstName}
|
||||
{receiver.lastName}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/korrespondenz?senderId={doc.sender.id}&receiverId={receiver.id}"
|
||||
class="text-ink-3 transition hover:text-accent"
|
||||
title={m.doc_conversation_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="font-serif text-sm text-ink-3 italic">{m.doc_no_receivers()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Doc = {
|
||||
summary?: string | null;
|
||||
transcription?: string | null;
|
||||
};
|
||||
|
||||
let { doc }: { doc: Doc } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center px-6 py-8">
|
||||
<div class="w-full max-w-prose space-y-8">
|
||||
{#if !doc.summary && !doc.transcription}
|
||||
<p class="font-serif text-sm text-ink-3 italic">—</p>
|
||||
{/if}
|
||||
|
||||
{#if doc.summary}
|
||||
<div>
|
||||
<span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.doc_label_summary()}
|
||||
</span>
|
||||
<p class="font-serif text-base leading-relaxed text-ink">{doc.summary}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if doc.transcription}
|
||||
<div>
|
||||
<span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.form_label_transcription()}
|
||||
</span>
|
||||
<p class="font-serif text-base leading-relaxed whitespace-pre-wrap text-ink">
|
||||
{doc.transcription}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,26 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||
import type { Annotation } from '$lib/types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number };
|
||||
|
||||
let {
|
||||
url,
|
||||
documentId = '',
|
||||
annotateMode = $bindable(false),
|
||||
transcribeMode = false,
|
||||
blockNumbers = {},
|
||||
activeAnnotationId = $bindable<string | null>(null),
|
||||
activeAnnotationPage = $bindable<number | null>(null),
|
||||
onAnnotationClick,
|
||||
onTranscriptionDraw,
|
||||
documentFileHash
|
||||
}: {
|
||||
url: string;
|
||||
documentId?: string;
|
||||
annotateMode?: boolean;
|
||||
transcribeMode?: boolean;
|
||||
blockNumbers?: Record<string, number>;
|
||||
activeAnnotationId?: string | null;
|
||||
activeAnnotationPage?: number | null;
|
||||
onAnnotationClick?: (id: string) => void;
|
||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||
documentFileHash?: string | null;
|
||||
} = $props();
|
||||
|
||||
@@ -45,10 +48,10 @@ let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
||||
let pdfjsReady = $state(false);
|
||||
|
||||
let annotations = $state<Annotation[]>([]);
|
||||
let annotateColor = $state('#ffff00');
|
||||
let commentCounts = new SvelteMap<string, number>();
|
||||
let showAnnotations = $state(true);
|
||||
|
||||
const TRANSCRIPTION_COLOR = '#00C7B1';
|
||||
|
||||
const visibleAnnotations = $derived(
|
||||
annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash)
|
||||
);
|
||||
@@ -164,81 +167,26 @@ async function prerender(doc: PDFDocumentProxy, pageNum: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommentCounts(docId: string, anns: Annotation[]) {
|
||||
await Promise.all(
|
||||
anns.map(async (a) => {
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${docId}/annotations/${a.id}/comments`);
|
||||
if (res.ok) {
|
||||
const threads = (await res.json()) as Array<{ replies: unknown[] }>;
|
||||
const total = threads.reduce((sum, t) => sum + 1 + t.replies.length, 0);
|
||||
commentCounts.set(a.id, total);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function loadAnnotations(docId: string) {
|
||||
if (!docId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${docId}/annotations`);
|
||||
if (res.ok) {
|
||||
annotations = await res.json();
|
||||
await loadCommentCounts(docId, annotations);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnnotationDraw(rect: { x: number; y: number; width: number; height: number }) {
|
||||
if (!documentId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/annotations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pageNumber: currentPage,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
color: annotateColor
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
const created: Annotation = await res.json();
|
||||
annotations = [...annotations, created];
|
||||
activeAnnotationId = created.id;
|
||||
activeAnnotationPage = created.pageNumber;
|
||||
onAnnotationClick?.(created.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnnotationDelete(annotationId: string) {
|
||||
if (!documentId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
annotations = annotations.filter((a) => a.id !== annotationId);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
|
||||
if (!documentId || !transcribeMode) return;
|
||||
await onTranscriptionDraw?.({ ...rect, pageNumber: currentPage });
|
||||
await loadAnnotations(documentId);
|
||||
}
|
||||
|
||||
function handleAnnotationClick(id: string) {
|
||||
activeAnnotationId = id;
|
||||
const ann = annotations.find((a) => a.id === id);
|
||||
activeAnnotationPage = ann?.pageNumber ?? null;
|
||||
onAnnotationClick?.(id);
|
||||
}
|
||||
|
||||
@@ -266,7 +214,33 @@ $effect(() => {
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (annotateMode) showAnnotations = true;
|
||||
if (transcribeMode) showAnnotations = true;
|
||||
});
|
||||
|
||||
// Scroll-sync: when activeAnnotationId changes, navigate to its page
|
||||
let prevActiveAnnotationId: string | null = null;
|
||||
$effect(() => {
|
||||
const id = activeAnnotationId;
|
||||
if (!id || id === prevActiveAnnotationId || !pdfDoc) {
|
||||
prevActiveAnnotationId = id;
|
||||
return;
|
||||
}
|
||||
prevActiveAnnotationId = id;
|
||||
|
||||
const ann = annotations.find((a) => a.id === id);
|
||||
if (!ann) return;
|
||||
|
||||
if (ann.pageNumber !== currentPage) {
|
||||
currentPage = ann.pageNumber;
|
||||
}
|
||||
|
||||
// After page renders, scroll the annotation into view (double-rAF for async render)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector(`[data-testid="annotation-${id}"]`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function prevPage() {
|
||||
@@ -412,16 +386,6 @@ function zoomOut() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Color picker (shown in annotate mode) -->
|
||||
{#if annotateMode}
|
||||
<input
|
||||
type="color"
|
||||
bind:value={annotateColor}
|
||||
aria-label="Farbe wählen"
|
||||
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent p-0"
|
||||
title="Farbe wählen"
|
||||
/>
|
||||
{/if}
|
||||
<!-- Annotation visibility toggle (shown when annotations exist) -->
|
||||
{#if annotations.length > 0}
|
||||
<button
|
||||
@@ -486,11 +450,11 @@ function zoomOut() {
|
||||
{#if showAnnotations}
|
||||
<AnnotationLayer
|
||||
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
|
||||
canAnnotate={annotateMode}
|
||||
color={annotateColor}
|
||||
onDraw={handleAnnotationDraw}
|
||||
onDelete={handleAnnotationDelete}
|
||||
commentCounts={Object.fromEntries(commentCounts)}
|
||||
canDraw={transcribeMode}
|
||||
color={TRANSCRIPTION_COLOR}
|
||||
blockNumbers={blockNumbers}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
onDraw={handleDraw}
|
||||
onAnnotationClick={handleAnnotationClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
34
frontend/src/lib/components/PersonChip.svelte
Normal file
34
frontend/src/lib/components/PersonChip.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { abbreviateName, personAvatarColor } from '$lib/utils/personFormat';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
|
||||
type Props = {
|
||||
person: Person;
|
||||
abbreviated: boolean;
|
||||
};
|
||||
|
||||
let { person, abbreviated }: Props = $props();
|
||||
|
||||
const displayName = $derived(
|
||||
abbreviated ? abbreviateName(person) : `${person.firstName} ${person.lastName}`
|
||||
);
|
||||
const avatarColor = $derived(personAvatarColor(person.id));
|
||||
const initials = $derived(
|
||||
`${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase()
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/persons/{person.id}"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-muted px-2 py-0.5 hover:border-primary hover:bg-surface focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<span
|
||||
class="flex h-[25px] w-[25px] shrink-0 items-center justify-center rounded-full text-[13px] font-bold text-white"
|
||||
style="background-color: {avatarColor}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<span class="text-[14px] font-semibold text-ink">{displayName}</span>
|
||||
</a>
|
||||
42
frontend/src/lib/components/PersonChipRow.svelte
Normal file
42
frontend/src/lib/components/PersonChipRow.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import PersonChip from './PersonChip.svelte';
|
||||
import OverflowPillDisplay from './OverflowPillDisplay.svelte';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
|
||||
type Props = {
|
||||
sender: Person | null | undefined;
|
||||
receivers: Person[];
|
||||
abbreviated: boolean;
|
||||
extraCount: number;
|
||||
};
|
||||
|
||||
let { sender, receivers, abbreviated, extraCount }: Props = $props();
|
||||
|
||||
const visibleReceivers = $derived(receivers.slice(0, 2));
|
||||
</script>
|
||||
|
||||
<div class="hidden min-w-0 items-center gap-1.5 overflow-hidden xs:flex">
|
||||
{#if sender}
|
||||
<PersonChip person={sender} abbreviated={abbreviated} />
|
||||
{/if}
|
||||
|
||||
{#if sender && receivers.length > 0}
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6 shrink-0 opacity-40"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#each visibleReceivers as receiver, i (receiver.id)}
|
||||
<span class={i === 1 ? 'hidden md:contents' : ''}>
|
||||
<PersonChip person={receiver} abbreviated={abbreviated} />
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
{#if extraCount > 0}
|
||||
<OverflowPillDisplay extraCount={extraCount} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
@@ -56,20 +57,6 @@ function selectPerson(person: Person) {
|
||||
function removePerson(id: string | undefined) {
|
||||
selectedPersons = selectedPersons.filter((p) => p.id !== id);
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
@@ -78,7 +65,7 @@ function clickOutside(node: HTMLElement) {
|
||||
<input type="hidden" name="receiverIds" value={person.id} />
|
||||
{/each}
|
||||
|
||||
<div class="relative" use:clickOutside>
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
<div
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { untrack } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
@@ -118,23 +119,9 @@ function selectPerson(person: Person) {
|
||||
showDropdown = false;
|
||||
onchange?.(person.id!);
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative" use:clickOutside>
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
<label
|
||||
for={name}
|
||||
class={compact
|
||||
@@ -154,8 +141,8 @@ function clickOutside(node: HTMLElement) {
|
||||
onfocus={handleFocus}
|
||||
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
||||
class={compact
|
||||
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:outline-none'
|
||||
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:border-accent focus:ring-accent'}
|
||||
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||
/>
|
||||
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
interface Props {
|
||||
tags?: string[];
|
||||
@@ -66,23 +67,9 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
||||
}
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||
showSuggestions = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full" use:clickOutside>
|
||||
<div class="w-full" use:clickOutside onclickoutside={() => (showSuggestions = false)}>
|
||||
<!-- Tag Container -->
|
||||
<div
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||
|
||||
@@ -31,12 +31,12 @@ function toggle() {
|
||||
onclick={toggle}
|
||||
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||
title={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||
class="rounded p-1.5 text-ink-2 transition-colors hover:bg-muted hover:text-ink"
|
||||
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{#if theme === 'dark'}
|
||||
<!-- Sun icon — click to go light -->
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -52,7 +52,7 @@ function toggle() {
|
||||
{:else}
|
||||
<!-- Moon icon — click to go dark -->
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
271
frontend/src/lib/components/TranscriptionBlock.svelte
Normal file
271
frontend/src/lib/components/TranscriptionBlock.svelte
Normal file
@@ -0,0 +1,271 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
||||
|
||||
type Props = {
|
||||
blockId: string;
|
||||
documentId: string;
|
||||
blockNumber: number;
|
||||
text: string;
|
||||
label: string | null;
|
||||
active: boolean;
|
||||
saveState: SaveState;
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
onTextChange: (text: string) => void;
|
||||
onFocus: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onRetry: () => void;
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
blockId,
|
||||
documentId,
|
||||
blockNumber,
|
||||
text,
|
||||
label = null,
|
||||
active,
|
||||
saveState,
|
||||
canComment,
|
||||
currentUserId,
|
||||
onTextChange,
|
||||
onFocus,
|
||||
onDeleteClick,
|
||||
onRetry,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
isFirst = false,
|
||||
isLast = false
|
||||
}: Props = $props();
|
||||
|
||||
let localText = $state(text);
|
||||
let commentOpen = $state(false);
|
||||
let commentCount = $state(0);
|
||||
let selectedQuote = $state<string | null>(null);
|
||||
let textareaEl = $state<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const hasComments = $derived(commentCount > 0);
|
||||
|
||||
// Sync from prop only when switching to a different block (not on save responses)
|
||||
let prevBlockId = $state(blockId);
|
||||
$effect(() => {
|
||||
if (blockId !== prevBlockId) {
|
||||
localText = text;
|
||||
prevBlockId = blockId;
|
||||
}
|
||||
});
|
||||
|
||||
let leftBorderClass = $derived(
|
||||
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
|
||||
);
|
||||
|
||||
function autoresize(node: HTMLTextAreaElement) {
|
||||
textareaEl = node;
|
||||
function resize() {
|
||||
node.style.height = 'auto';
|
||||
node.style.height = `${node.scrollHeight}px`;
|
||||
}
|
||||
|
||||
resize();
|
||||
|
||||
return {
|
||||
update() {
|
||||
resize();
|
||||
},
|
||||
destroy() {
|
||||
textareaEl = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLTextAreaElement;
|
||||
localText = target.value;
|
||||
onTextChange(target.value);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (confirm(m.transcription_block_delete_confirm())) {
|
||||
onDeleteClick();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextareaMouseUp() {
|
||||
if (!textareaEl) return;
|
||||
const start = textareaEl.selectionStart;
|
||||
const end = textareaEl.selectionEnd;
|
||||
if (start !== end) {
|
||||
selectedQuote = localText.substring(start, end);
|
||||
} else {
|
||||
selectedQuote = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative flex overflow-visible rounded border border-line {leftBorderClass}"
|
||||
data-block-id={blockId}
|
||||
>
|
||||
<!-- Turquoise numbered badge — overlaps top-left of card -->
|
||||
<span
|
||||
class="absolute -top-2 -left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-turquoise text-xs font-bold text-turquoise-fg shadow-sm"
|
||||
>
|
||||
{blockNumber}
|
||||
</span>
|
||||
|
||||
<!-- Drag handle (desktop) / Arrow buttons (mobile) -->
|
||||
<div class="flex shrink-0 flex-col items-center justify-center border-r border-line px-1">
|
||||
<!-- Mobile: arrow buttons -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
|
||||
disabled={isFirst}
|
||||
aria-label="Nach oben"
|
||||
onclick={() => onMoveUp?.()}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Desktop: grip handle (drag target) -->
|
||||
<div
|
||||
class="hidden cursor-grab text-ink-3 transition-colors select-none hover:text-ink active:cursor-grabbing md:block"
|
||||
data-drag-handle
|
||||
aria-label="Ziehen zum Sortieren"
|
||||
>
|
||||
⠿
|
||||
</div>
|
||||
<!-- Mobile: arrow down -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
|
||||
disabled={isLast}
|
||||
aria-label="Nach unten"
|
||||
onclick={() => onMoveDown?.()}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 p-4 pl-3">
|
||||
<!-- Header -->
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
{#if label}
|
||||
<span class="text-xs font-medium tracking-wide text-ink-2 uppercase">
|
||||
{label}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Textarea -->
|
||||
<textarea
|
||||
use:autoresize={localText}
|
||||
class="w-full resize-none border-none bg-transparent font-serif text-base leading-relaxed text-ink outline-none placeholder:text-ink-3"
|
||||
placeholder={m.transcription_block_placeholder()}
|
||||
rows={1}
|
||||
value={localText}
|
||||
oninput={handleInput}
|
||||
onfocus={onFocus}
|
||||
onmouseup={handleTextareaMouseUp}
|
||||
></textarea>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between border-t border-line pt-2">
|
||||
<div>
|
||||
{#if !hasComments}
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-1 text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
onclick={() => (commentOpen = true)}
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
||||
/>
|
||||
</svg>
|
||||
{m.transcription_block_comment_btn()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Save state indicator -->
|
||||
{#if saveState === 'saving'}
|
||||
<span class="animate-pulse text-xs text-ink-3">
|
||||
{m.transcription_block_save_saving()}
|
||||
</span>
|
||||
{:else if saveState === 'saved' || saveState === 'fading'}
|
||||
<span
|
||||
class="text-xs text-green-600 transition-opacity duration-300 {saveState === 'fading' ? 'opacity-0' : 'opacity-100'}"
|
||||
>
|
||||
{m.transcription_block_save_saved()} <span class="inline-block">✓</span>
|
||||
</span>
|
||||
{:else if saveState === 'error'}
|
||||
<span class="text-error text-xs">
|
||||
{m.transcription_block_save_error()}
|
||||
<span class="mx-1">—</span>
|
||||
<button
|
||||
type="button"
|
||||
class="underline transition-colors hover:text-ink"
|
||||
onclick={onRetry}
|
||||
>
|
||||
{m.transcription_block_save_retry()}
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-error cursor-pointer text-ink-3 transition-colors"
|
||||
aria-label={m.btn_delete()}
|
||||
onclick={handleDelete}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comment thread — list always visible, compose toggled by Kommentieren -->
|
||||
<div class="mt-3">
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
blockId={blockId}
|
||||
loadOnMount={true}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
quotedText={selectedQuote}
|
||||
showCompose={commentOpen}
|
||||
onCountChange={(count) => (commentCount = count)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
161
frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts
Normal file
161
frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderBlock(overrides: Record<string, unknown> = {}) {
|
||||
return render(TranscriptionBlock, {
|
||||
blockId: 'block-1',
|
||||
documentId: 'doc-1',
|
||||
blockNumber: 3,
|
||||
text: 'Liebe Mutter,',
|
||||
label: null,
|
||||
active: false,
|
||||
saveState: 'idle' as const,
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
onTextChange: vi.fn(),
|
||||
onFocus: vi.fn(),
|
||||
onDeleteClick: vi.fn(),
|
||||
onRetry: vi.fn(),
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Rendering ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionBlock — rendering', () => {
|
||||
it('renders block number in turquoise badge', async () => {
|
||||
renderBlock();
|
||||
await expect.element(page.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders text in textarea', async () => {
|
||||
renderBlock();
|
||||
const textarea = page.getByRole('textbox');
|
||||
await expect.element(textarea).toHaveValue('Liebe Mutter,');
|
||||
});
|
||||
|
||||
it('renders optional label when provided', async () => {
|
||||
renderBlock({ label: 'Anrede' });
|
||||
await expect.element(page.getByText('Anrede')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render label when null', async () => {
|
||||
renderBlock({ label: null });
|
||||
const label = page.getByText('Anrede');
|
||||
await expect.element(label).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Save states ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionBlock — save states', () => {
|
||||
it('shows nothing in idle state', async () => {
|
||||
renderBlock({ saveState: 'idle' });
|
||||
const saving = page.getByText('Speichere...');
|
||||
await expect.element(saving).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Speichere..." in saving state', async () => {
|
||||
renderBlock({ saveState: 'saving' });
|
||||
await expect.element(page.getByText('Speichere...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Gespeichert" in saved state', async () => {
|
||||
renderBlock({ saveState: 'saved' });
|
||||
await expect.element(page.getByText(/Gespeichert/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error with retry button in error state', async () => {
|
||||
const onRetry = vi.fn();
|
||||
renderBlock({ saveState: 'error', onRetry });
|
||||
await expect.element(page.getByText('Nicht gespeichert')).toBeInTheDocument();
|
||||
const retryBtn = page.getByText('Erneut versuchen');
|
||||
await expect.element(retryBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Active state ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionBlock — active border', () => {
|
||||
it('has turquoise left border when active', async () => {
|
||||
renderBlock({ active: true });
|
||||
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
|
||||
const block = document.querySelector('[data-block-id="block-1"]')!;
|
||||
expect(block.className).toContain('border-turquoise');
|
||||
});
|
||||
|
||||
it('has error left border when save failed', async () => {
|
||||
renderBlock({ saveState: 'error' });
|
||||
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
|
||||
const block = document.querySelector('[data-block-id="block-1"]')!;
|
||||
expect(block.className).toContain('border-error');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interactions ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionBlock — interactions', () => {
|
||||
it('calls onTextChange when typing in textarea', async () => {
|
||||
const onTextChange = vi.fn();
|
||||
renderBlock({ onTextChange });
|
||||
const textarea = page.getByRole('textbox');
|
||||
await textarea.fill('Neue Zeile');
|
||||
expect(onTextChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onFocus when textarea is focused', async () => {
|
||||
const onFocus = vi.fn();
|
||||
renderBlock({ onFocus });
|
||||
const textarea = page.getByRole('textbox');
|
||||
await textarea.click();
|
||||
expect(onFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows Kommentieren button when no comments exist', async () => {
|
||||
renderBlock();
|
||||
const btn = page.getByText('Kommentieren');
|
||||
await expect.element(btn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Reorder controls ────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionBlock — reorder controls', () => {
|
||||
it('shows a drag handle element', async () => {
|
||||
renderBlock();
|
||||
const handle = document.querySelector('[data-drag-handle]');
|
||||
expect(handle).not.toBeNull();
|
||||
});
|
||||
|
||||
it('disables move-up button when isFirst', async () => {
|
||||
renderBlock({ isFirst: true });
|
||||
const btn = page.getByRole('button', { name: 'Nach oben' });
|
||||
await expect.element(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables move-down button when isLast', async () => {
|
||||
renderBlock({ isLast: true });
|
||||
const btn = page.getByRole('button', { name: 'Nach unten' });
|
||||
await expect.element(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls onMoveUp when up arrow clicked', async () => {
|
||||
const onMoveUp = vi.fn();
|
||||
renderBlock({ onMoveUp, isFirst: false });
|
||||
const btn = page.getByRole('button', { name: 'Nach oben' });
|
||||
await btn.click();
|
||||
expect(onMoveUp).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onMoveDown when down arrow clicked', async () => {
|
||||
const onMoveDown = vi.fn();
|
||||
renderBlock({ onMoveDown, isLast: false });
|
||||
const btn = page.getByRole('button', { name: 'Nach unten' });
|
||||
await btn.click();
|
||||
expect(onMoveDown).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
322
frontend/src/lib/components/TranscriptionEditView.svelte
Normal file
322
frontend/src/lib/components/TranscriptionEditView.svelte
Normal file
@@ -0,0 +1,322 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
blocks: TranscriptionBlockData[];
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
onBlockFocus: (blockId: string) => void;
|
||||
onSaveBlock: (blockId: string, text: string) => Promise<void>;
|
||||
onDeleteBlock: (blockId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
blocks,
|
||||
canComment,
|
||||
currentUserId,
|
||||
onBlockFocus,
|
||||
onSaveBlock,
|
||||
onDeleteBlock
|
||||
}: Props = $props();
|
||||
|
||||
let activeBlockId: string | null = $state(null);
|
||||
let saveStates = new SvelteMap<string, SaveState>();
|
||||
let debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
|
||||
let pendingTexts = new SvelteMap<string, string>();
|
||||
let sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
let hasBlocks = $derived(blocks.length > 0);
|
||||
|
||||
function getSaveState(blockId: string): SaveState {
|
||||
return saveStates.get(blockId) ?? 'idle';
|
||||
}
|
||||
|
||||
function setSaveState(blockId: string, state: SaveState) {
|
||||
saveStates.set(blockId, state);
|
||||
}
|
||||
|
||||
async function executeSave(blockId: string) {
|
||||
const text = pendingTexts.get(blockId);
|
||||
if (text === undefined) return;
|
||||
|
||||
pendingTexts.delete(blockId);
|
||||
setSaveState(blockId, 'saving');
|
||||
|
||||
try {
|
||||
await onSaveBlock(blockId, text);
|
||||
setSaveState(blockId, 'saved');
|
||||
scheduleSavedFade(blockId);
|
||||
} catch {
|
||||
setSaveState(blockId, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSavedFade(blockId: string) {
|
||||
setTimeout(() => {
|
||||
if (getSaveState(blockId) === 'saved') {
|
||||
setSaveState(blockId, 'fading');
|
||||
setTimeout(() => {
|
||||
if (getSaveState(blockId) === 'fading') {
|
||||
setSaveState(blockId, 'idle');
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function scheduleDebounce(blockId: string) {
|
||||
clearDebounce(blockId);
|
||||
const timer = setTimeout(() => {
|
||||
debounceTimers.delete(blockId);
|
||||
executeSave(blockId);
|
||||
}, 1500);
|
||||
debounceTimers.set(blockId, timer);
|
||||
}
|
||||
|
||||
function clearDebounce(blockId: string) {
|
||||
const existing = debounceTimers.get(blockId);
|
||||
if (existing !== undefined) {
|
||||
clearTimeout(existing);
|
||||
debounceTimers.delete(blockId);
|
||||
}
|
||||
}
|
||||
|
||||
function flushAllPending() {
|
||||
for (const [blockId] of debounceTimers) {
|
||||
clearDebounce(blockId);
|
||||
executeSave(blockId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextChange(blockId: string, text: string) {
|
||||
pendingTexts.set(blockId, text);
|
||||
scheduleDebounce(blockId);
|
||||
}
|
||||
|
||||
function handleFocus(blockId: string) {
|
||||
activeBlockId = blockId;
|
||||
onBlockFocus(blockId);
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
flushAllPending();
|
||||
}
|
||||
|
||||
async function handleRetry(blockId: string) {
|
||||
const block = blocks.find((b) => b.id === blockId);
|
||||
if (!block) return;
|
||||
|
||||
const pending = pendingTexts.get(blockId);
|
||||
const text = pending ?? block.text;
|
||||
pendingTexts.set(blockId, text);
|
||||
await executeSave(blockId);
|
||||
}
|
||||
|
||||
function handleDelete(blockId: string) {
|
||||
clearDebounce(blockId);
|
||||
pendingTexts.delete(blockId);
|
||||
saveStates.delete(blockId);
|
||||
onDeleteBlock(blockId);
|
||||
}
|
||||
|
||||
async function reorder(newOrder: string[]) {
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ blockIds: newOrder })
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated = await res.json();
|
||||
// Update blocks with new sort orders from server
|
||||
for (const b of updated) {
|
||||
const existing = blocks.find((x) => x.id === b.id);
|
||||
if (existing) existing.sortOrder = b.sortOrder;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoveUp(blockId: string) {
|
||||
const sorted = [...sortedBlocks];
|
||||
const idx = sorted.findIndex((b) => b.id === blockId);
|
||||
if (idx <= 0) return;
|
||||
[sorted[idx - 1], sorted[idx]] = [sorted[idx], sorted[idx - 1]];
|
||||
reorder(sorted.map((b) => b.id));
|
||||
}
|
||||
|
||||
function handleMoveDown(blockId: string) {
|
||||
const sorted = [...sortedBlocks];
|
||||
const idx = sorted.findIndex((b) => b.id === blockId);
|
||||
if (idx < 0 || idx >= sorted.length - 1) return;
|
||||
[sorted[idx], sorted[idx + 1]] = [sorted[idx + 1], sorted[idx]];
|
||||
reorder(sorted.map((b) => b.id));
|
||||
}
|
||||
|
||||
// ── Pointer-based drag and drop ──────────────────────────────────────────
|
||||
|
||||
let draggedBlockId: string | null = $state(null);
|
||||
let dropTargetIdx: number | null = $state(null);
|
||||
let dragOffsetY: number = $state(0);
|
||||
let dragStartY = 0;
|
||||
let capturedEl: HTMLElement | null = null;
|
||||
let listEl: HTMLElement | null = null;
|
||||
|
||||
function handleGripDown(e: PointerEvent, blockId: string) {
|
||||
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
|
||||
e.preventDefault();
|
||||
draggedBlockId = blockId;
|
||||
dragStartY = e.clientY;
|
||||
dragOffsetY = 0;
|
||||
capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement;
|
||||
capturedEl?.setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!draggedBlockId || !listEl) return;
|
||||
dragOffsetY = e.clientY - dragStartY;
|
||||
|
||||
const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]'));
|
||||
const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId);
|
||||
let target: number | null = null;
|
||||
|
||||
for (let i = 0; i < wrappers.length; i++) {
|
||||
const rect = wrappers[i].getBoundingClientRect();
|
||||
if (e.clientY < rect.top + rect.height / 2) {
|
||||
target = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (target === null) target = wrappers.length;
|
||||
if (target === dragIdx || target === dragIdx + 1) target = null;
|
||||
dropTargetIdx = target;
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
if (!draggedBlockId) return;
|
||||
|
||||
if (dropTargetIdx !== null) {
|
||||
const sorted = [...sortedBlocks];
|
||||
const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId);
|
||||
if (fromIdx >= 0) {
|
||||
const [moved] = sorted.splice(fromIdx, 1);
|
||||
const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx;
|
||||
sorted.splice(insertAt, 0, moved);
|
||||
reorder(sorted.map((b) => b.id));
|
||||
}
|
||||
}
|
||||
|
||||
draggedBlockId = null;
|
||||
dropTargetIdx = null;
|
||||
dragOffsetY = 0;
|
||||
capturedEl = null;
|
||||
}
|
||||
|
||||
function flushViaBeacon() {
|
||||
for (const [blockId, text] of pendingTexts) {
|
||||
clearDebounce(blockId);
|
||||
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
|
||||
const body = JSON.stringify({ text });
|
||||
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
|
||||
pendingTexts.delete(blockId);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
function onBeforeUnload() {
|
||||
flushViaBeacon();
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
for (const timer of debounceTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col overflow-y-auto bg-surface p-4">
|
||||
{#if hasBlocks}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex flex-col gap-3"
|
||||
bind:this={listEl}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
>
|
||||
{#each sortedBlocks as block, i (block.id)}
|
||||
{#if dropTargetIdx === i}
|
||||
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
data-block-wrapper
|
||||
onblur={handleBlur}
|
||||
onpointerdown={(e) => handleGripDown(e, block.id)}
|
||||
class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
|
||||
style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''}
|
||||
>
|
||||
<TranscriptionBlock
|
||||
blockId={block.id}
|
||||
documentId={documentId}
|
||||
blockNumber={i + 1}
|
||||
text={block.text}
|
||||
label={block.label}
|
||||
active={activeBlockId === block.id}
|
||||
saveState={getSaveState(block.id)}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
onTextChange={(text) => handleTextChange(block.id, text)}
|
||||
onFocus={() => handleFocus(block.id)}
|
||||
onDeleteClick={() => handleDelete(block.id)}
|
||||
onRetry={() => handleRetry(block.id)}
|
||||
onMoveUp={() => handleMoveUp(block.id)}
|
||||
onMoveDown={() => handleMoveDown(block.id)}
|
||||
isFirst={i === 0}
|
||||
isLast={i === sortedBlocks.length - 1}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if dropTargetIdx === sortedBlocks.length}
|
||||
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Next block CTA — dashed outline hint -->
|
||||
<div
|
||||
class="flex items-center justify-center rounded border border-dashed border-line px-4 py-5 text-center font-sans text-sm text-ink-3"
|
||||
>
|
||||
{m.transcription_next_block_cta({ number: sortedBlocks.length + 1 })}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<svg
|
||||
class="mb-4 h-16 w-16 text-ink-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="max-w-xs text-sm leading-relaxed text-ink-3">
|
||||
{m.transcription_empty_cta()}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscriptionEditView from './TranscriptionEditView.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const block1 = {
|
||||
id: 'b1',
|
||||
annotationId: 'a1',
|
||||
documentId: 'doc-1',
|
||||
text: 'Block eins',
|
||||
label: null,
|
||||
sortOrder: 0,
|
||||
version: 0
|
||||
};
|
||||
const block2 = {
|
||||
id: 'b2',
|
||||
annotationId: 'a2',
|
||||
documentId: 'doc-1',
|
||||
text: 'Block zwei',
|
||||
label: null,
|
||||
sortOrder: 1,
|
||||
version: 0
|
||||
};
|
||||
|
||||
function renderView(overrides: Record<string, unknown> = {}) {
|
||||
return render(TranscriptionEditView, {
|
||||
documentId: 'doc-1',
|
||||
blocks: [block1, block2],
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
onBlockFocus: vi.fn(),
|
||||
onSaveBlock: vi.fn(),
|
||||
onDeleteBlock: vi.fn(),
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
describe('TranscriptionEditView — rendering', () => {
|
||||
it('renders blocks in sort order', async () => {
|
||||
renderView();
|
||||
const textareas = page.getByRole('textbox').all();
|
||||
expect(textareas.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('shows next-block CTA after block list', async () => {
|
||||
renderView();
|
||||
await expect.element(page.getByText(/Block 3/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no blocks', async () => {
|
||||
renderView({ blocks: [] });
|
||||
await expect.element(page.getByText(/Markiere einen Bereich/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TranscriptionEditView — reorder', () => {
|
||||
it('renders move-up button disabled on first block', async () => {
|
||||
renderView();
|
||||
const upButtons = page.getByRole('button', { name: 'Nach oben' }).all();
|
||||
// First block's up button should be disabled
|
||||
await expect.element(upButtons[0]).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders move-down button disabled on last block', async () => {
|
||||
renderView();
|
||||
const downButtons = page.getByRole('button', { name: 'Nach unten' }).all();
|
||||
// Last block's down button should be disabled
|
||||
await expect.element(downButtons[downButtons.length - 1]).toBeDisabled();
|
||||
});
|
||||
|
||||
it('has a drag handle on each block', async () => {
|
||||
renderView();
|
||||
const handles = document.querySelectorAll('[data-drag-handle]');
|
||||
expect(handles.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
|
||||
titleDirty = true;
|
||||
}}
|
||||
required={titleRequired}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -65,7 +65,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
|
||||
name="documentLocation"
|
||||
value={initialDocumentLocation}
|
||||
placeholder={m.form_placeholder_archive_location()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
|
||||
name="summary"
|
||||
rows="5"
|
||||
placeholder={m.form_placeholder_content()}
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>{initialSummary}</textarea
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ let { initialTranscription = '' }: { initialTranscription?: string } = $props();
|
||||
name="transcription"
|
||||
rows="12"
|
||||
placeholder={m.form_placeholder_transcription()}
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>{initialTranscription}</textarea
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ $effect(() => {
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm
|
||||
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-ink focus:ring-ink'}"
|
||||
{dateInvalid ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||
/>
|
||||
<input type="hidden" name="documentDate" value={dateIso} />
|
||||
@@ -91,7 +91,7 @@ $effect(() => {
|
||||
name="location"
|
||||
value={initialLocation}
|
||||
placeholder={m.form_placeholder_location()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ let selected = $derived([...selectedGroupIds]);
|
||||
name="groupIds"
|
||||
value={group.id}
|
||||
bind:group={selected}
|
||||
class="rounded border-line text-ink focus:ring-accent"
|
||||
class="rounded border-line text-ink focus:ring-focus-ring"
|
||||
/>
|
||||
{group.name}
|
||||
</label>
|
||||
|
||||
@@ -13,7 +13,7 @@ let { required = false }: { required?: boolean } = $props();
|
||||
type="password"
|
||||
name="newPassword"
|
||||
required={required}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -25,7 +25,7 @@ let { required = false }: { required?: boolean } = $props();
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
required={required}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ function handleBirthDateInput(e: Event) {
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={firstName}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -49,7 +49,7 @@ function handleBirthDateInput(e: Event) {
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={lastName}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -63,7 +63,7 @@ function handleBirthDateInput(e: Event) {
|
||||
placeholder="TT.MM.JJJJ"
|
||||
value={birthDateDisplay}
|
||||
oninput={handleBirthDateInput}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<input type="hidden" name="birthDate" value={birthDateIso} />
|
||||
</label>
|
||||
@@ -76,7 +76,7 @@ function handleBirthDateInput(e: Event) {
|
||||
type="email"
|
||||
name="email"
|
||||
value={email}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -88,7 +88,7 @@ function handleBirthDateInput(e: Event) {
|
||||
name="contact"
|
||||
rows="3"
|
||||
placeholder={m.profile_contact_placeholder()}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>{contact}</textarea
|
||||
>
|
||||
</label>
|
||||
|
||||
@@ -18,6 +18,8 @@ export type ErrorCode =
|
||||
| 'INVALID_RESET_TOKEN'
|
||||
| 'ANNOTATION_NOT_FOUND'
|
||||
| 'ANNOTATION_OVERLAP'
|
||||
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
|
||||
| 'TRANSCRIPTION_BLOCK_CONFLICT'
|
||||
| 'COMMENT_NOT_FOUND'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
@@ -74,6 +76,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_annotation_not_found();
|
||||
case 'ANNOTATION_OVERLAP':
|
||||
return m.error_annotation_overlap();
|
||||
case 'TRANSCRIPTION_BLOCK_NOT_FOUND':
|
||||
return m.error_transcription_block_not_found();
|
||||
case 'TRANSCRIPTION_BLOCK_CONFLICT':
|
||||
return m.error_transcription_block_conflict();
|
||||
case 'COMMENT_NOT_FOUND':
|
||||
return m.error_comment_not_found();
|
||||
case 'UNAUTHORIZED':
|
||||
|
||||
@@ -27,6 +27,16 @@ export type Comment = {
|
||||
|
||||
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||
|
||||
export type TranscriptionBlockData = {
|
||||
id: string;
|
||||
annotationId: string;
|
||||
documentId: string;
|
||||
text: string;
|
||||
label: string | null;
|
||||
sortOrder: number;
|
||||
version: number;
|
||||
};
|
||||
|
||||
export type Annotation = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
|
||||
@@ -31,6 +31,63 @@ export function germanToIso(german: string): string {
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a raw date string into German DD.MM.YYYY format.
|
||||
*
|
||||
* Handles two modes:
|
||||
* - Pure digit stream (no dots): auto-inserts dots after position 2 and 4
|
||||
* - Manual dot entry: preserves user-typed dots, pads single-digit day/month,
|
||||
* and overflows extra digits from day→month and month→year
|
||||
*/
|
||||
export function formatGermanDateInput(raw: string): string {
|
||||
if (!raw.includes('.')) {
|
||||
const digits = raw.replace(/\D/g, '').slice(0, 8);
|
||||
if (digits.length <= 2) return digits;
|
||||
if (digits.length <= 4) return `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
return `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
}
|
||||
|
||||
const trailingDot = raw.endsWith('.');
|
||||
const parts = raw.split('.').map((p) => p.replace(/\D/g, ''));
|
||||
|
||||
let day = parts[0] ?? '';
|
||||
let month = parts[1] ?? '';
|
||||
let year = parts[2] ?? '';
|
||||
|
||||
let dayOverflowed = false;
|
||||
if (day.length > 2) {
|
||||
month = day.slice(2) + month;
|
||||
day = day.slice(0, 2);
|
||||
dayOverflowed = true;
|
||||
}
|
||||
|
||||
let monthOverflowed = false;
|
||||
if (month.length > 2) {
|
||||
year = month.slice(2) + year;
|
||||
month = month.slice(0, 2);
|
||||
monthOverflowed = true;
|
||||
}
|
||||
|
||||
year = year.slice(0, 4);
|
||||
|
||||
const afterDay = !dayOverflowed && parts.length >= 2;
|
||||
|
||||
if (day.length === 1 && (month || (trailingDot && !dayOverflowed))) {
|
||||
day = '0' + day;
|
||||
}
|
||||
if (month.length === 1 && (year || (trailingDot && afterDay && !monthOverflowed))) {
|
||||
month = '0' + month;
|
||||
}
|
||||
|
||||
if (year) return `${day}.${month}.${year}`;
|
||||
if (month) {
|
||||
const dot2 = trailingDot && afterDay && !monthOverflowed ? '.' : '';
|
||||
return `${day}.${month}${dot2}`;
|
||||
}
|
||||
const dot1 = trailingDot && !dayOverflowed ? '.' : '';
|
||||
return `${day}${dot1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a date input event for German-format date fields (DD.MM.YYYY).
|
||||
* Strips non-digits, formats with dots, mutates the input's displayed value,
|
||||
@@ -38,15 +95,7 @@ export function germanToIso(german: string): string {
|
||||
*/
|
||||
export function handleGermanDateInput(e: Event): { display: string; iso: string } {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const digits = input.value.replace(/\D/g, '').slice(0, 8);
|
||||
let display: string;
|
||||
if (digits.length <= 2) {
|
||||
display = digits;
|
||||
} else if (digits.length <= 4) {
|
||||
display = `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
} else {
|
||||
display = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
}
|
||||
const display = formatGermanDateInput(input.value);
|
||||
input.value = display;
|
||||
return { display, iso: germanToIso(display) };
|
||||
}
|
||||
|
||||
171
frontend/src/lib/utils/personFormat.spec.ts
Normal file
171
frontend/src/lib/utils/personFormat.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
abbreviateName,
|
||||
formatXsMeta,
|
||||
personAvatarColor,
|
||||
formatDate,
|
||||
statusDotClass,
|
||||
statusLabel
|
||||
} from './personFormat';
|
||||
|
||||
// ─── abbreviateName ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('abbreviateName', () => {
|
||||
it('abbreviates first name to initial + last name', () => {
|
||||
expect(abbreviateName({ firstName: 'Karl', lastName: 'Raddatz' })).toBe('K. Raddatz');
|
||||
});
|
||||
|
||||
it('returns single name as-is when no last name', () => {
|
||||
expect(abbreviateName({ firstName: 'Elfriede', lastName: '' })).toBe('Elfriede');
|
||||
});
|
||||
|
||||
it('preserves hyphenated last name', () => {
|
||||
expect(abbreviateName({ firstName: 'Karl', lastName: 'Müller-Schmidt' })).toBe(
|
||||
'K. Müller-Schmidt'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles leading/trailing whitespace in names', () => {
|
||||
expect(abbreviateName({ firstName: ' Karl ', lastName: ' Raddatz ' })).toBe('K. Raddatz');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatXsMeta ────────────────────────────────────────────────────────────
|
||||
|
||||
type Doc = {
|
||||
sender?: { firstName: string; lastName: string } | null;
|
||||
receivers?: { firstName: string; lastName: string }[];
|
||||
documentDate?: string | null;
|
||||
};
|
||||
|
||||
describe('formatXsMeta', () => {
|
||||
const sender = { firstName: 'Karl', lastName: 'Raddatz' };
|
||||
const receiver1 = { firstName: 'Elfriede', lastName: 'Raddatz' };
|
||||
const receiver2 = { firstName: 'Anna', lastName: 'Müller' };
|
||||
const receiver3 = { firstName: 'Hans', lastName: 'Schmidt' };
|
||||
|
||||
it('formats sender with no receivers and date', () => {
|
||||
const doc: Doc = { sender, receivers: [], documentDate: '1943-12-24' };
|
||||
expect(formatXsMeta(doc)).toBe('K.Raddatz · 24.12.1943');
|
||||
});
|
||||
|
||||
it('formats sender with one receiver and date', () => {
|
||||
const doc: Doc = { sender, receivers: [receiver1], documentDate: '1943-12-24' };
|
||||
expect(formatXsMeta(doc)).toBe('K.Raddatz → E.Raddatz · 24.12.1943');
|
||||
});
|
||||
|
||||
it('formats sender with three receivers showing +2', () => {
|
||||
const doc: Doc = {
|
||||
sender,
|
||||
receivers: [receiver1, receiver2, receiver3],
|
||||
documentDate: '1943-12-24'
|
||||
};
|
||||
expect(formatXsMeta(doc)).toBe('K.Raddatz → E.Raddatz +2 · 24.12.1943');
|
||||
});
|
||||
|
||||
it('formats without sender', () => {
|
||||
const doc: Doc = { sender: null, receivers: [receiver1], documentDate: '1943-12-24' };
|
||||
expect(formatXsMeta(doc)).toBe('E.Raddatz · 24.12.1943');
|
||||
});
|
||||
|
||||
it('formats without date', () => {
|
||||
const doc: Doc = { sender, receivers: [], documentDate: null };
|
||||
expect(formatXsMeta(doc)).toBe('K.Raddatz');
|
||||
});
|
||||
|
||||
it('formats with no sender and no date', () => {
|
||||
const doc: Doc = { sender: null, receivers: [receiver1], documentDate: null };
|
||||
expect(formatXsMeta(doc)).toBe('E.Raddatz');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── personAvatarColor ───────────────────────────────────────────────────────
|
||||
|
||||
const PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'];
|
||||
|
||||
describe('personAvatarColor', () => {
|
||||
it('returns a value from the palette', () => {
|
||||
expect(PALETTE).toContain(personAvatarColor('abc'));
|
||||
});
|
||||
|
||||
it('is deterministic — same id always returns same color', () => {
|
||||
const id = '550e8400-e29b-41d4-a716-446655440000';
|
||||
expect(personAvatarColor(id)).toBe(personAvatarColor(id));
|
||||
});
|
||||
|
||||
it('all 5 palette entries are reachable across 1000 random UUIDs', () => {
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
seen.add(personAvatarColor(crypto.randomUUID()));
|
||||
}
|
||||
expect(seen.size).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatDate ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats short date as dd.mm.yyyy', () => {
|
||||
expect(formatDate('1943-12-24', 'short')).toBe('24.12.1943');
|
||||
});
|
||||
|
||||
it('formats long date with German month name', () => {
|
||||
expect(formatDate('1943-12-24', 'long')).toBe('24. Dezember 1943');
|
||||
});
|
||||
|
||||
it('does not shift Dec 31 to Jan 1 (UTC off-by-one guard)', () => {
|
||||
expect(formatDate('1943-12-31', 'short')).toBe('31.12.1943');
|
||||
});
|
||||
|
||||
it('does not shift Jan 1 to Dec 31 (UTC off-by-one guard)', () => {
|
||||
expect(formatDate('1944-01-01', 'short')).toBe('01.01.1944');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── statusDotClass ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('statusDotClass', () => {
|
||||
it('PLACEHOLDER → bg-gray-400', () => {
|
||||
expect(statusDotClass('PLACEHOLDER')).toBe('bg-gray-400');
|
||||
});
|
||||
|
||||
it('UPLOADED → bg-emerald-500', () => {
|
||||
expect(statusDotClass('UPLOADED')).toBe('bg-emerald-500');
|
||||
});
|
||||
|
||||
it('TRANSCRIBED → bg-blue-400', () => {
|
||||
expect(statusDotClass('TRANSCRIBED')).toBe('bg-blue-400');
|
||||
});
|
||||
|
||||
it('REVIEWED → bg-amber-400', () => {
|
||||
expect(statusDotClass('REVIEWED')).toBe('bg-amber-400');
|
||||
});
|
||||
|
||||
it('ARCHIVED → bg-emerald-600', () => {
|
||||
expect(statusDotClass('ARCHIVED')).toBe('bg-emerald-600');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── statusLabel ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('statusLabel', () => {
|
||||
it('PLACEHOLDER → "Platzhalter"', () => {
|
||||
expect(statusLabel('PLACEHOLDER')).toBe('Platzhalter');
|
||||
});
|
||||
|
||||
it('UPLOADED → "Hochgeladen"', () => {
|
||||
expect(statusLabel('UPLOADED')).toBe('Hochgeladen');
|
||||
});
|
||||
|
||||
it('TRANSCRIBED → "Transkribiert"', () => {
|
||||
expect(statusLabel('TRANSCRIBED')).toBe('Transkribiert');
|
||||
});
|
||||
|
||||
it('REVIEWED → "Geprüft"', () => {
|
||||
expect(statusLabel('REVIEWED')).toBe('Geprüft');
|
||||
});
|
||||
|
||||
it('ARCHIVED → "Archiviert"', () => {
|
||||
expect(statusLabel('ARCHIVED')).toBe('Archiviert');
|
||||
});
|
||||
});
|
||||
102
frontend/src/lib/utils/personFormat.ts
Normal file
102
frontend/src/lib/utils/personFormat.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { formatDocumentStatus } from './documentStatusLabel';
|
||||
|
||||
type Person = { firstName: string; lastName: string };
|
||||
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
|
||||
type DocForMeta = {
|
||||
sender?: Person | null;
|
||||
receivers?: Person[];
|
||||
documentDate?: string | null;
|
||||
};
|
||||
|
||||
const AVATAR_PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'] as const;
|
||||
|
||||
function djb2(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash * 33) ^ str.charCodeAt(i);
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function abbreviateName(person: Person): string {
|
||||
const first = person.firstName.trim();
|
||||
const last = person.lastName.trim();
|
||||
if (!last) return first;
|
||||
return `${first.charAt(0)}. ${last}`;
|
||||
}
|
||||
|
||||
function abbreviateCompact(person: Person): string {
|
||||
const first = person.firstName.trim();
|
||||
const last = person.lastName.trim();
|
||||
if (!last) return first;
|
||||
return `${first.charAt(0)}.${last}`;
|
||||
}
|
||||
|
||||
export function formatXsMeta(doc: DocForMeta): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
const receivers = doc.receivers ?? [];
|
||||
if (doc.sender) {
|
||||
const senderAbbr = abbreviateCompact(doc.sender);
|
||||
if (receivers.length === 0) {
|
||||
parts.push(senderAbbr);
|
||||
} else {
|
||||
const extra = receivers.length - 1;
|
||||
const firstReceiver = abbreviateCompact(receivers[0]);
|
||||
parts.push(
|
||||
extra > 0
|
||||
? `${senderAbbr} → ${firstReceiver} +${extra}`
|
||||
: `${senderAbbr} → ${firstReceiver}`
|
||||
);
|
||||
}
|
||||
} else if (receivers.length > 0) {
|
||||
const extra = receivers.length - 1;
|
||||
const firstReceiver = abbreviateCompact(receivers[0]);
|
||||
parts.push(extra > 0 ? `${firstReceiver} +${extra}` : firstReceiver);
|
||||
}
|
||||
|
||||
if (doc.documentDate) {
|
||||
parts.push(formatDate(doc.documentDate, 'short'));
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function personAvatarColor(personId: string): string {
|
||||
return AVATAR_PALETTE[djb2(personId) % AVATAR_PALETTE.length];
|
||||
}
|
||||
|
||||
export function formatDate(isoDate: string, format: 'short' | 'long'): string {
|
||||
const date = new Date(isoDate + 'T12:00:00');
|
||||
if (format === 'short') {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
}).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function statusDotClass(status: DocumentStatus): string {
|
||||
switch (status) {
|
||||
case 'PLACEHOLDER':
|
||||
return 'bg-gray-400';
|
||||
case 'UPLOADED':
|
||||
return 'bg-emerald-500';
|
||||
case 'TRANSCRIBED':
|
||||
return 'bg-blue-400';
|
||||
case 'REVIEWED':
|
||||
return 'bg-amber-400';
|
||||
case 'ARCHIVED':
|
||||
return 'bg-emerald-600';
|
||||
}
|
||||
}
|
||||
|
||||
export function statusLabel(status: string): string {
|
||||
return formatDocumentStatus(status);
|
||||
}
|
||||
@@ -35,7 +35,8 @@ const userInitials = $derived.by(() => {
|
||||
|
||||
<div class="min-h-screen bg-canvas" data-hydrated={hydrated || undefined}>
|
||||
{#if !isAuthPage}
|
||||
<header class="sticky top-0 z-50 border-b border-line-2 bg-surface">
|
||||
<header class="sticky top-0 z-50 bg-header">
|
||||
<div class="h-1 bg-accent"></div>
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 justify-between">
|
||||
<!-- Logo & Nav -->
|
||||
@@ -45,9 +46,9 @@ const userInitials = $derived.by(() => {
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Language selector (desktop only — mobile lives in nav drawer) -->
|
||||
<div
|
||||
class="hidden items-center gap-1 border-r border-line pr-3 sm:flex [&_button]:px-1.5 [&_button]:py-1 [&_button]:text-xs"
|
||||
class="hidden items-center gap-1 pr-3 lg:flex [&_button]:px-1.5 [&_button]:py-1 [&_button]:text-xs"
|
||||
>
|
||||
<LanguageSwitcher />
|
||||
<LanguageSwitcher inverted />
|
||||
</div>
|
||||
|
||||
<!-- Theme toggle -->
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createApiClient } from '$lib/api.server';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
|
||||
type NotificationDTO = components['schemas']['NotificationDTO'];
|
||||
type StatsDTO = components['schemas']['StatsDTO'];
|
||||
type Document = components['schemas']['Document'];
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
@@ -55,19 +55,19 @@ export async function load({ url, fetch }) {
|
||||
const receiverObj = allPersons.find((p) => p.id === receiverId);
|
||||
|
||||
// Dashboard widgets — failures are isolated and don't crash the page
|
||||
let mentions: NotificationDTO[] = [];
|
||||
let stats: StatsDTO | null = null;
|
||||
let incompleteDocs: IncompleteDocumentDTO[] = [];
|
||||
let recentDocs: Document[] = [];
|
||||
|
||||
if (isDashboard) {
|
||||
const [mentionsResult, incompleteResult, recentResult] = await Promise.allSettled([
|
||||
api.GET('/api/notifications', { params: { query: { size: 5 } } }),
|
||||
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
|
||||
const [statsResult, incompleteResult, recentResult] = 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 } } })
|
||||
]);
|
||||
|
||||
if (mentionsResult.status === 'fulfilled' && mentionsResult.value.response.ok) {
|
||||
mentions = mentionsResult.value.data?.content ?? [];
|
||||
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 ?? [];
|
||||
@@ -80,7 +80,7 @@ export async function load({ url, fetch }) {
|
||||
return {
|
||||
isDashboard,
|
||||
documents,
|
||||
mentions,
|
||||
stats,
|
||||
incompleteDocs,
|
||||
recentDocs,
|
||||
initialValues: {
|
||||
@@ -96,7 +96,7 @@ export async function load({ url, fetch }) {
|
||||
return {
|
||||
isDashboard,
|
||||
documents: [],
|
||||
mentions: [],
|
||||
stats: null,
|
||||
incompleteDocs: [],
|
||||
recentDocs: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
|
||||
@@ -6,7 +6,6 @@ import SearchFilterBar from './SearchFilterBar.svelte';
|
||||
import DropZone from './DropZone.svelte';
|
||||
import DocumentList from './DocumentList.svelte';
|
||||
import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte';
|
||||
import DashboardMentions from '$lib/components/DashboardMentions.svelte';
|
||||
import DashboardNeedsMetadata from '$lib/components/DashboardNeedsMetadata.svelte';
|
||||
import DashboardRecentDocuments from '$lib/components/DashboardRecentDocuments.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
@@ -69,6 +68,11 @@ $effect(() => {
|
||||
tagNames = data.filters?.tags || [];
|
||||
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);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -94,20 +98,25 @@ $effect(() => {
|
||||
{#if data.isDashboard}
|
||||
<DashboardResumeStrip />
|
||||
|
||||
{#if data.canWrite}
|
||||
<div class="mt-4">
|
||||
<DropZone />
|
||||
</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="mt-6 grid gap-4 {(data.mentions?.length ?? 0) > 0 && (data.incompleteDocs?.length ?? 0) > 0 ? 'lg:grid-cols-2' : ''}"
|
||||
>
|
||||
<DashboardMentions mentions={data.mentions ?? []} />
|
||||
<DashboardNeedsMetadata incompleteDocs={data.incompleteDocs ?? []} />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} />
|
||||
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
|
||||
</div>
|
||||
{:else}
|
||||
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
|
||||
|
||||
@@ -28,53 +28,53 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="mr-10 hidden flex-shrink-0 items-center md:flex">
|
||||
<div class="flex items-stretch">
|
||||
<div class="mr-10 flex flex-shrink-0 items-center">
|
||||
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans text-xl font-bold tracking-widest text-ink uppercase"
|
||||
<span class="font-sans text-xl font-bold tracking-widest text-white uppercase"
|
||||
>Familienarchiv</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Desktop nav -->
|
||||
<nav class="hidden items-center sm:flex sm:space-x-1">
|
||||
<nav class="hidden items-stretch lg:flex lg:space-x-1">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||
? 'rounded bg-nav-active text-ink'
|
||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'border-b-2 border-accent text-white'
|
||||
: 'text-white/70 hover:text-white'}"
|
||||
>
|
||||
{m.nav_documents()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/persons"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{page.url.pathname.startsWith('/persons')
|
||||
? 'rounded bg-nav-active text-ink'
|
||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'border-b-2 border-accent text-white'
|
||||
: 'text-white/70 hover:text-white'}"
|
||||
>
|
||||
{m.nav_persons()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/korrespondenz"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{page.url.pathname.startsWith('/korrespondenz')
|
||||
? 'rounded bg-nav-active text-ink'
|
||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'border-b-2 border-accent text-white'
|
||||
: 'text-white/70 hover:text-white'}"
|
||||
>
|
||||
{m.nav_conversations()}
|
||||
</a>
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{page.url.pathname.startsWith('/admin')
|
||||
? 'rounded bg-nav-active text-ink'
|
||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'border-b-2 border-accent text-white'
|
||||
: 'text-white/70 hover:text-white'}"
|
||||
>
|
||||
{m.nav_admin()}
|
||||
</a>
|
||||
@@ -83,7 +83,7 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
|
||||
<!-- Hamburger toggle (mobile only) -->
|
||||
<button
|
||||
class="ml-auto flex h-11 w-11 items-center justify-center rounded text-ink-2 transition-colors hover:bg-muted hover:text-ink sm:hidden"
|
||||
class="ml-auto flex h-11 w-11 items-center justify-center self-center rounded text-white/70 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring lg:hidden"
|
||||
aria-label={mobileNavOpen ? 'Menü schließen' : 'Menü öffnen'}
|
||||
aria-expanded={mobileNavOpen}
|
||||
aria-controls="mobile-nav"
|
||||
@@ -131,41 +131,41 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
<!-- Mobile nav overlay -->
|
||||
{#if mobileNavOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 top-[68px] z-40 sm:hidden" onkeydown={handleOverlayKeydown}>
|
||||
<div class="fixed inset-0 top-[68px] z-40 lg:hidden" onkeydown={handleOverlayKeydown}>
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="absolute inset-0 bg-black/20" onclick={closeMobileNav}></div>
|
||||
|
||||
<!-- Panel -->
|
||||
<!-- Panel — white background with navy text (reverses the dark header) -->
|
||||
<div class="relative border-b border-line bg-surface shadow-md">
|
||||
<nav id="mobile-nav">
|
||||
<a
|
||||
href="/"
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||
? 'bg-nav-active text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'bg-accent-bg text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_documents()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/persons"
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||
{page.url.pathname.startsWith('/persons')
|
||||
? 'bg-nav-active text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'bg-accent-bg text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_persons()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/korrespondenz"
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||
{page.url.pathname.startsWith('/korrespondenz')
|
||||
? 'bg-nav-active text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'bg-accent-bg text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_conversations()}
|
||||
</a>
|
||||
@@ -173,10 +173,10 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||
{page.url.pathname.startsWith('/admin')
|
||||
? 'bg-nav-active text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'bg-accent-bg text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_admin()}
|
||||
</a>
|
||||
|
||||
19
frontend/src/routes/AuthHeader.svelte
Normal file
19
frontend/src/routes/AuthHeader.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||
</script>
|
||||
|
||||
<header class="bg-header">
|
||||
<div class="h-1 bg-accent"></div>
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-12 items-center justify-between">
|
||||
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans text-sm font-bold tracking-widest text-white uppercase"
|
||||
>Familienarchiv</span
|
||||
>
|
||||
</a>
|
||||
<div class="flex items-center gap-1">
|
||||
<LanguageSwitcher inverted />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -46,7 +46,7 @@ let {
|
||||
onblur={onblur}
|
||||
aria-label={m.docs_search_placeholder()}
|
||||
placeholder={m.docs_search_placeholder()}
|
||||
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<img
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
let { userInitials }: { userInitials: string | null } = $props();
|
||||
|
||||
let userMenuOpen = $state(false);
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
userMenuOpen = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative"
|
||||
{@attach clickOutside}
|
||||
use:clickOutside
|
||||
onclickoutside={() => (userMenuOpen = false)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') userMenuOpen = false;
|
||||
}}
|
||||
@@ -33,7 +23,7 @@ function clickOutside(node: HTMLElement) {
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="true"
|
||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-sans text-xs font-bold text-primary-fg transition-opacity hover:opacity-80"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-white font-sans text-xs font-bold text-brand-navy transition-opacity hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{userInitials}
|
||||
</button>
|
||||
@@ -44,13 +34,13 @@ function clickOutside(node: HTMLElement) {
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="true"
|
||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
|
||||
class="group rounded-sm p-2 transition-colors hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 opacity-50"
|
||||
class="h-5 w-5 opacity-65 brightness-0 invert transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -63,7 +63,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
>
|
||||
<!-- Desktop-only heading -->
|
||||
<div
|
||||
class="hidden px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/30 uppercase lg:block"
|
||||
class="hidden px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/50 uppercase lg:block"
|
||||
>
|
||||
{m.admin_heading()}
|
||||
</div>
|
||||
@@ -123,7 +123,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/20'}">
|
||||
<span class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/50'}">
|
||||
{userCount}
|
||||
</span>
|
||||
<span
|
||||
@@ -190,7 +190,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/20'}">
|
||||
<span class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/50'}">
|
||||
{groupCount}
|
||||
</span>
|
||||
<span
|
||||
@@ -259,7 +259,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||
</svg>
|
||||
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/20'}">
|
||||
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/50'}">
|
||||
{tagCount}
|
||||
</span>
|
||||
<span
|
||||
@@ -355,7 +355,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
transition:fly={{ x: -160, duration: 180 }}
|
||||
>
|
||||
<!-- Heading -->
|
||||
<div class="px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/30 uppercase">
|
||||
<div class="px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/50 uppercase">
|
||||
{m.admin_heading()}
|
||||
</div>
|
||||
|
||||
@@ -384,7 +384,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/20'}"
|
||||
class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/50'}"
|
||||
>
|
||||
{userCount}
|
||||
</span>
|
||||
@@ -422,7 +422,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/20'}"
|
||||
class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/50'}"
|
||||
>
|
||||
{groupCount}
|
||||
</span>
|
||||
@@ -460,7 +460,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||
</svg>
|
||||
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/20'}">
|
||||
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/50'}">
|
||||
{tagCount}
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -133,7 +133,7 @@ const ADMIN_PERMISSIONS = $derived([
|
||||
name="name"
|
||||
value={data.group.name}
|
||||
required
|
||||
class="bg-background w-full rounded-sm border border-line px-3 py-2 font-sans text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
|
||||
class="bg-background w-full rounded-sm border border-line px-3 py-2 font-sans text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +150,7 @@ const ADMIN_PERMISSIONS = $derived([
|
||||
name="permissions"
|
||||
value={perm.value}
|
||||
checked={data.group.permissions.includes(perm.value)}
|
||||
class="h-4 w-4 rounded border-line text-primary focus:ring-primary"
|
||||
class="h-4 w-4 rounded border-line text-primary focus:ring-focus-ring"
|
||||
/>
|
||||
{perm.label}
|
||||
</label>
|
||||
|
||||
@@ -109,10 +109,12 @@ describe('GroupsListPanel — collapse toggle', () => {
|
||||
});
|
||||
|
||||
it('persists collapse state using the groups-specific localStorage key', async () => {
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
render(GroupsListPanel, { groups });
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true');
|
||||
await vi.waitFor(() =>
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true')
|
||||
);
|
||||
setSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ beforeNavigate(({ cancel, to }) => {
|
||||
name="name"
|
||||
placeholder={m.admin_group_name_placeholder()}
|
||||
required
|
||||
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:ring-1 focus:ring-primary focus:outline-none"
|
||||
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,7 @@ beforeNavigate(({ cancel, to }) => {
|
||||
type="checkbox"
|
||||
name="permissions"
|
||||
value={perm.value}
|
||||
class="rounded border-line text-primary focus:ring-primary"
|
||||
class="rounded border-line text-primary focus:ring-focus-ring"
|
||||
/>
|
||||
<span class="font-mono text-xs font-bold uppercase">{perm.value}</span>
|
||||
<span class="text-ink-3">— {perm.label}</span>
|
||||
@@ -146,7 +146,7 @@ beforeNavigate(({ cancel, to }) => {
|
||||
type="checkbox"
|
||||
name="permissions"
|
||||
value={perm.value}
|
||||
class="rounded border-line text-primary focus:ring-primary"
|
||||
class="rounded border-line text-primary focus:ring-focus-ring"
|
||||
/>
|
||||
<span class="font-mono text-xs font-bold uppercase">{perm.value}</span>
|
||||
<span class="font-normal text-ink-3">— {perm.label}</span>
|
||||
|
||||
@@ -104,7 +104,7 @@ $effect(() => {
|
||||
name="name"
|
||||
value={data.tag.name}
|
||||
required
|
||||
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:ring-1 focus:ring-primary focus:outline-none"
|
||||
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -88,10 +88,12 @@ describe('TagsListPanel — collapse toggle', () => {
|
||||
});
|
||||
|
||||
it('persists collapse state using the tags-specific localStorage key', async () => {
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
render(TagsListPanel, { tags });
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true');
|
||||
await vi.waitFor(() =>
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true')
|
||||
);
|
||||
setSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user