Compare commits
5 Commits
46d64f50a5
...
1efd3d8e23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1efd3d8e23 | ||
|
|
5211e0b9f7 | ||
|
|
234f83c40b | ||
|
|
a46b1a2e84 | ||
|
|
5231476c27 |
@@ -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.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 = resolveUserId(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 = resolveUserId(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,
|
||||||
|
Authentication authentication) {
|
||||||
|
UUID userId = resolveUserId(authentication);
|
||||||
|
transcriptionService.deleteBlock(documentId, blockId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/reorder")
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public List<TranscriptionBlock> reorderBlocks(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@RequestBody ReorderTranscriptionBlocksDTO dto) {
|
||||||
|
return transcriptionService.reorderBlocks(documentId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{blockId}/history")
|
||||||
|
@RequirePermission(Permission.READ_ALL)
|
||||||
|
public List<TranscriptionBlockVersion> getBlockHistory(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID blockId) {
|
||||||
|
return transcriptionService.getBlockHistory(documentId, blockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID resolveUserId(Authentication authentication) {
|
||||||
|
if (authentication == null || !authentication.isAuthenticated()) return null;
|
||||||
|
try {
|
||||||
|
AppUser user = userService.findByUsername(authentication.getName());
|
||||||
|
return user != null ? user.getId() : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Could not resolve user for transcription: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CreateTranscriptionBlockDTO {
|
||||||
|
private int pageNumber;
|
||||||
|
private double x;
|
||||||
|
private double y;
|
||||||
|
private double width;
|
||||||
|
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 */
|
/** The new annotation overlaps an existing one on the same page. 409 */
|
||||||
ANNOTATION_OVERLAP,
|
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 ---
|
// --- Comments ---
|
||||||
/** The comment with the given ID does not exist. 404 */
|
/** The comment with the given ID does not exist. 404 */
|
||||||
COMMENT_NOT_FOUND,
|
COMMENT_NOT_FOUND,
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ public class DocumentComment {
|
|||||||
@Column(name = "annotation_id")
|
@Column(name = "annotation_id")
|
||||||
private UUID annotationId;
|
private UUID annotationId;
|
||||||
|
|
||||||
|
@Column(name = "block_id")
|
||||||
|
private UUID blockId;
|
||||||
|
|
||||||
@Column(name = "parent_id")
|
@Column(name = "parent_id")
|
||||||
private UUID parentId;
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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.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 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);
|
||||||
|
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, UUID userId) {
|
||||||
|
TranscriptionBlock block = getBlock(documentId, blockId);
|
||||||
|
// CASCADE deletes annotation, versions, and comments via DB constraints
|
||||||
|
blockRepository.delete(block);
|
||||||
|
annotationService.deleteAnnotation(documentId, block.getAnnotationId(), userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<TranscriptionBlock> 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);
|
||||||
|
}
|
||||||
|
return blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sanitizeText(String text) {
|
||||||
|
if (text == null) return "";
|
||||||
|
// Strip any HTML tags — textarea content should be plain text only
|
||||||
|
String cleaned = text.replaceAll("<[^>]*>", "");
|
||||||
|
if (cleaned.length() > MAX_TEXT_LENGTH) {
|
||||||
|
cleaned = cleaned.substring(0, MAX_TEXT_LENGTH);
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 CASCADE,
|
||||||
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL DEFAULT '',
|
||||||
|
label VARCHAR(200),
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
version INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_by UUID REFERENCES app_users(id) ON DELETE SET NULL,
|
||||||
|
updated_by UUID REFERENCES app_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 app_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);
|
||||||
@@ -17,6 +17,7 @@ bun.lockb
|
|||||||
/src/lib/generated/
|
/src/lib/generated/
|
||||||
/src/lib/paraglide/
|
/src/lib/paraglide/
|
||||||
/src/lib/paraglide_bak*/
|
/src/lib/paraglide_bak*/
|
||||||
|
/src/paraglide/
|
||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
/test-results/
|
/test-results/
|
||||||
|
|||||||
@@ -423,5 +423,33 @@
|
|||||||
"notification_empty_history_body": "Hier erscheinen Erwähnungen und Antworten auf deine Kommentare.",
|
"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_row_aria": "{actor} {type} auf \u201e{title}\u201c \u2014 {time} \u2014 {readState}",
|
||||||
"notification_read_state_read": "gelesen",
|
"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_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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,5 +423,33 @@
|
|||||||
"notification_empty_history_body": "Mentions and replies to your comments will appear here.",
|
"notification_empty_history_body": "Mentions and replies to your comments will appear here.",
|
||||||
"notification_row_aria": "{actor} {type} on \"{title}\" — {time} — {readState}",
|
"notification_row_aria": "{actor} {type} on \"{title}\" — {time} — {readState}",
|
||||||
"notification_read_state_read": "read",
|
"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_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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,5 +423,33 @@
|
|||||||
"notification_empty_history_body": "Aquí aparecerán las menciones y respuestas a tus comentarios.",
|
"notification_empty_history_body": "Aquí aparecerán las menciones y respuestas a tus comentarios.",
|
||||||
"notification_row_aria": "{actor} {type} en \"{title}\" — {time} — {readState}",
|
"notification_row_aria": "{actor} {type} en \"{title}\" — {time} — {readState}",
|
||||||
"notification_read_state_read": "leído",
|
"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_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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
import { formatDate } from '$lib/utils/personFormat';
|
import { formatDate } from '$lib/utils/personFormat';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
import PersonChipRow from './PersonChipRow.svelte';
|
import PersonChipRow from './PersonChipRow.svelte';
|
||||||
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
|
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
|
||||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||||
|
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
|
||||||
|
|
||||||
type Person = { id: string; firstName: string; lastName: string };
|
type Person = { id: string; firstName: string; lastName: string };
|
||||||
|
type Tag = { id: string; name: string };
|
||||||
|
|
||||||
type Doc = {
|
type Doc = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +20,9 @@ type Doc = {
|
|||||||
receivers?: Person[] | null;
|
receivers?: Person[] | null;
|
||||||
filePath?: string | null;
|
filePath?: string | null;
|
||||||
contentType?: string | null;
|
contentType?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
tags?: Tag[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -25,9 +31,19 @@ type Props = {
|
|||||||
canAnnotate: boolean;
|
canAnnotate: boolean;
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
annotateMode: boolean;
|
annotateMode: boolean;
|
||||||
|
transcribeMode: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { doc, canWrite, canAnnotate, fileUrl, annotateMode = $bindable() }: Props = $props();
|
let {
|
||||||
|
doc,
|
||||||
|
canWrite,
|
||||||
|
canAnnotate,
|
||||||
|
fileUrl,
|
||||||
|
annotateMode = $bindable(),
|
||||||
|
transcribeMode = $bindable()
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let detailsOpen = $state(false);
|
||||||
|
|
||||||
const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('application/pdf'));
|
const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('application/pdf'));
|
||||||
const receivers = $derived(doc.receivers ?? []);
|
const receivers = $derived(doc.receivers ?? []);
|
||||||
@@ -84,6 +100,65 @@ let mobileMenuOpen = $state(false);
|
|||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet transcribeBtn(mobile: boolean)}
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
transcribeMode = true;
|
||||||
|
annotateMode = false;
|
||||||
|
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-[#00C7B1] px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-[#00C7B1] hover:text-white 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-[#00C7B1] px-3 py-2 text-left text-[16px] text-white transition focus-visible:ring-2 focus-visible:ring-primary'
|
||||||
|
: 'flex items-center gap-1.5 rounded bg-[#00C7B1] px-3 py-1.5 font-sans text-[16px] font-medium text-white 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)}
|
{#snippet downloadLink(mobile: boolean)}
|
||||||
<a
|
<a
|
||||||
href={fileUrl}
|
href={fileUrl}
|
||||||
@@ -155,12 +230,41 @@ let mobileMenuOpen = $state(false);
|
|||||||
<OverflowPillButton extraCount={extraCount} persons={overflowPersons} />
|
<OverflowPillButton extraCount={extraCount} persons={overflowPersons} />
|
||||||
{/if}
|
{/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 rounded border px-2 py-1 font-sans text-xs 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 -->
|
<!-- Divider between metadata and actions -->
|
||||||
<div class="mx-3 hidden h-6 w-px shrink-0 bg-line md:block"></div>
|
<div class="mx-3 hidden h-6 w-px shrink-0 bg-line md:block"></div>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
<div class="flex shrink-0 items-center gap-1.5 pr-3 font-sans">
|
<div class="flex shrink-0 items-center gap-1.5 pr-3 font-sans">
|
||||||
{#if canAnnotate && isPdf && !annotateMode}
|
{#if canWrite && isPdf && !transcribeMode && !annotateMode}
|
||||||
|
{@render transcribeBtn(false)}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if transcribeMode}
|
||||||
|
{@render transcribeStopBtn(false)}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if canAnnotate && isPdf && !annotateMode && !transcribeMode}
|
||||||
{@render annotateBtn(false)}
|
{@render annotateBtn(false)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -168,7 +272,7 @@ let mobileMenuOpen = $state(false);
|
|||||||
{@render annotateStopBtn(false)}
|
{@render annotateStopBtn(false)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canWrite && !annotateMode}
|
{#if canWrite && !annotateMode && !transcribeMode}
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}/edit"
|
href="/documents/{doc.id}/edit"
|
||||||
aria-label={m.btn_edit()}
|
aria-label={m.btn_edit()}
|
||||||
@@ -217,7 +321,11 @@ let mobileMenuOpen = $state(false);
|
|||||||
role="menu"
|
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"
|
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 canAnnotate && isPdf && !annotateMode}
|
{#if canWrite && isPdf && !transcribeMode && !annotateMode}
|
||||||
|
{@render transcribeBtn(true)}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if canAnnotate && isPdf && !annotateMode && !transcribeMode}
|
||||||
{@render annotateBtn(true)}
|
{@render annotateBtn(true)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -233,4 +341,18 @@ let mobileMenuOpen = $state(false);
|
|||||||
|
|
||||||
<!-- Hint strip — only when annotateMode, only at ≥768px -->
|
<!-- Hint strip — only when annotateMode, only at ≥768px -->
|
||||||
<AnnotateHintStrip annotateMode={annotateMode} />
|
<AnnotateHintStrip annotateMode={annotateMode} />
|
||||||
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
|
|||||||
158
frontend/src/lib/components/TranscriptionBlock.svelte
Normal file
158
frontend/src/lib/components/TranscriptionBlock.svelte
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
blockId: string;
|
||||||
|
blockNumber: number;
|
||||||
|
text: string;
|
||||||
|
label: string | null;
|
||||||
|
active: boolean;
|
||||||
|
saveState: SaveState;
|
||||||
|
onTextChange: (text: string) => void;
|
||||||
|
onFocus: () => void;
|
||||||
|
onCommentClick: () => void;
|
||||||
|
onDeleteClick: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
blockId,
|
||||||
|
blockNumber,
|
||||||
|
text,
|
||||||
|
label = null,
|
||||||
|
active,
|
||||||
|
saveState,
|
||||||
|
onTextChange,
|
||||||
|
onFocus,
|
||||||
|
onCommentClick,
|
||||||
|
onDeleteClick,
|
||||||
|
onRetry
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let leftBorderClass = $derived(
|
||||||
|
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-[#00C7B1]' : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
function autoresize(node: HTMLTextAreaElement) {
|
||||||
|
function resize() {
|
||||||
|
node.style.height = 'auto';
|
||||||
|
node.style.height = `${node.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
resize();
|
||||||
|
|
||||||
|
return {
|
||||||
|
update() {
|
||||||
|
resize();
|
||||||
|
},
|
||||||
|
destroy() {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(event: Event) {
|
||||||
|
const target = event.target as HTMLTextAreaElement;
|
||||||
|
onTextChange(target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (confirm(m.transcription_block_delete_confirm())) {
|
||||||
|
onDeleteClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded border border-line {leftBorderClass}" data-block-id={blockId}>
|
||||||
|
<div class="p-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="flex h-6 w-6 items-center justify-center rounded-full bg-[#002850] text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
{blockNumber}
|
||||||
|
</span>
|
||||||
|
{#if label}
|
||||||
|
<span class="text-xs font-medium tracking-wide text-ink-2 uppercase">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Textarea -->
|
||||||
|
<textarea
|
||||||
|
use:autoresize={text}
|
||||||
|
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={text}
|
||||||
|
oninput={handleInput}
|
||||||
|
onfocus={onFocus}
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex items-center justify-between border-t border-line pt-2">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
||||||
|
onclick={onCommentClick}
|
||||||
|
>
|
||||||
|
{m.transcription_block_comment_btn()}
|
||||||
|
</button>
|
||||||
|
{#if active}
|
||||||
|
<span class="text-xs text-ink-3">
|
||||||
|
{m.transcription_block_quote_hint()}
|
||||||
|
</span>
|
||||||
|
{/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'}
|
||||||
|
<span class="text-xs text-green-600">
|
||||||
|
{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 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
183
frontend/src/lib/components/TranscriptionEditView.svelte
Normal file
183
frontend/src/lib/components/TranscriptionEditView.svelte
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||||
|
|
||||||
|
type TranscriptionBlockData = {
|
||||||
|
id: string;
|
||||||
|
annotationId: string;
|
||||||
|
documentId: string;
|
||||||
|
text: string;
|
||||||
|
label: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
blocks: TranscriptionBlockData[];
|
||||||
|
onBlockFocus: (blockId: string) => void;
|
||||||
|
onSaveBlock: (blockId: string, text: string) => Promise<void>;
|
||||||
|
onDeleteBlock: (blockId: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { blocks, 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, 'idle');
|
||||||
|
}
|
||||||
|
}, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCommentClick() {
|
||||||
|
// Placeholder for future comment functionality
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
function onBeforeUnload() {
|
||||||
|
flushAllPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each sortedBlocks as block, i (block.id)}
|
||||||
|
<div onblur={handleBlur}>
|
||||||
|
<TranscriptionBlock
|
||||||
|
blockId={block.id}
|
||||||
|
blockNumber={i + 1}
|
||||||
|
text={block.text}
|
||||||
|
label={block.label}
|
||||||
|
active={activeBlockId === block.id}
|
||||||
|
saveState={getSaveState(block.id)}
|
||||||
|
onTextChange={(text) => handleTextChange(block.id, text)}
|
||||||
|
onFocus={() => handleFocus(block.id)}
|
||||||
|
onCommentClick={handleCommentClick}
|
||||||
|
onDeleteClick={() => handleDelete(block.id)}
|
||||||
|
onRetry={() => handleRetry(block.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</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>
|
||||||
@@ -18,6 +18,8 @@ export type ErrorCode =
|
|||||||
| 'INVALID_RESET_TOKEN'
|
| 'INVALID_RESET_TOKEN'
|
||||||
| 'ANNOTATION_NOT_FOUND'
|
| 'ANNOTATION_NOT_FOUND'
|
||||||
| 'ANNOTATION_OVERLAP'
|
| 'ANNOTATION_OVERLAP'
|
||||||
|
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
|
||||||
|
| 'TRANSCRIPTION_BLOCK_CONFLICT'
|
||||||
| 'COMMENT_NOT_FOUND'
|
| 'COMMENT_NOT_FOUND'
|
||||||
| 'UNAUTHORIZED'
|
| 'UNAUTHORIZED'
|
||||||
| 'FORBIDDEN'
|
| 'FORBIDDEN'
|
||||||
@@ -74,6 +76,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_annotation_not_found();
|
return m.error_annotation_not_found();
|
||||||
case 'ANNOTATION_OVERLAP':
|
case 'ANNOTATION_OVERLAP':
|
||||||
return m.error_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':
|
case 'COMMENT_NOT_FOUND':
|
||||||
return m.error_comment_not_found();
|
return m.error_comment_not_found();
|
||||||
case 'UNAUTHORIZED':
|
case 'UNAUTHORIZED':
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { env } from '$env/dynamic/private';
|
|
||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export async function load({ params, fetch }) {
|
export async function load({ params, fetch }) {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
const base = env.API_INTERNAL_URL || 'http://localhost:8080';
|
|
||||||
|
|
||||||
const [docResult, commentsRes] = await Promise.all([
|
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id } } });
|
||||||
api.GET('/api/documents/{id}', { params: { path: { id } } }),
|
|
||||||
fetch(`${base}/api/documents/${id}/comments`).catch(() => null)
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (docResult.response.status === 401) throw redirect(302, '/login');
|
if (docResult.response.status === 401) throw redirect(302, '/login');
|
||||||
|
|
||||||
@@ -20,14 +15,5 @@ export async function load({ params, fetch }) {
|
|||||||
throw error(docResult.response.status, getErrorMessage(code));
|
throw error(docResult.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
let comments: unknown[] = [];
|
return { document: docResult.data! };
|
||||||
if (commentsRes?.ok) {
|
|
||||||
try {
|
|
||||||
comments = await commentsRes.json();
|
|
||||||
} catch {
|
|
||||||
// ignore invalid response
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { document: docResult.data!, comments };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { onMount } from 'svelte';
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
|
||||||
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
|
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
|
||||||
import type { DocumentPanelTab } from '$lib/types';
|
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -13,6 +12,7 @@ const targetCommentId = $derived(page.url.searchParams.get('commentId'));
|
|||||||
const targetAnnotationId = $derived(page.url.searchParams.get('annotationId'));
|
const targetAnnotationId = $derived(page.url.searchParams.get('annotationId'));
|
||||||
|
|
||||||
const doc = $derived(data.document);
|
const doc = $derived(data.document);
|
||||||
|
const canWrite = $derived(data.canWrite ?? false);
|
||||||
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
||||||
const canAdmin = $derived(
|
const canAdmin = $derived(
|
||||||
(data.user?.groups as Array<{ permissions: string[] }> | undefined)?.some((g) =>
|
(data.user?.groups as Array<{ permissions: string[] }> | undefined)?.some((g) =>
|
||||||
@@ -56,44 +56,90 @@ async function loadFile(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Annotation state (lifted from PdfViewer) ──────────────────────────────────
|
// ── Mode state (mutually exclusive) ──────────────────────────────────────────
|
||||||
|
|
||||||
let annotateMode = $state(false);
|
let annotateMode = $state(false);
|
||||||
|
let transcribeMode = $state(false);
|
||||||
let activeAnnotationId = $state<string | null>(null);
|
let activeAnnotationId = $state<string | null>(null);
|
||||||
let activeAnnotationPage = $state<number | null>(null);
|
let activeAnnotationPage = $state<number | null>(null);
|
||||||
|
|
||||||
// Close the panel when entering annotate mode so the PDF is fully visible.
|
// Mode exclusivity: entering one mode exits the other
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (annotateMode) panelOpen = false;
|
if (annotateMode && transcribeMode) {
|
||||||
|
transcribeMode = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Bottom panel state ────────────────────────────────────────────────────────
|
// ── Transcription blocks ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type TranscriptionBlockData = {
|
||||||
|
id: string;
|
||||||
|
annotationId: string;
|
||||||
|
documentId: string;
|
||||||
|
text: string;
|
||||||
|
label: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let transcriptionBlocks = $state<TranscriptionBlockData[]>([]);
|
||||||
|
|
||||||
|
async function loadTranscriptionBlocks() {
|
||||||
|
if (!doc?.id) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks`);
|
||||||
|
if (res.ok) {
|
||||||
|
transcriptionBlocks = await res.json();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load transcription blocks:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBlock(blockId: string, text: string) {
|
||||||
|
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Save failed');
|
||||||
|
const updated = await res.json();
|
||||||
|
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBlock(blockId: string) {
|
||||||
|
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Delete failed');
|
||||||
|
transcriptionBlocks = transcriptionBlocks.filter((b) => b.id !== blockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlockFocus(blockId: string) {
|
||||||
|
const block = transcriptionBlocks.find((b) => b.id === blockId);
|
||||||
|
if (block) {
|
||||||
|
activeAnnotationId = block.annotationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load blocks when transcribe mode is entered
|
||||||
|
$effect(() => {
|
||||||
|
if (transcribeMode) {
|
||||||
|
loadTranscriptionBlocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Navigation / init ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let panelOpen = $state(false);
|
|
||||||
let panelHeight = $state(0); // set to full height on mount
|
|
||||||
let navHeight = $state(0);
|
let navHeight = $state(0);
|
||||||
let activeTab = $state<DocumentPanelTab>('metadata');
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
||||||
|
|
||||||
const topbar = document.querySelector('[data-topbar]');
|
|
||||||
panelHeight = window.innerHeight - navHeight - (topbar?.getBoundingClientRect().height ?? 0);
|
|
||||||
|
|
||||||
if (targetAnnotationId) {
|
if (targetAnnotationId) {
|
||||||
// Deep-link into an annotation comment: open the side panel
|
|
||||||
activeAnnotationId = targetAnnotationId;
|
activeAnnotationId = targetAnnotationId;
|
||||||
} else if (targetCommentId) {
|
|
||||||
// Deep-link into a document-level comment: open discussion tab
|
|
||||||
panelOpen = true;
|
|
||||||
activeTab = 'discussion';
|
|
||||||
} else if (!doc?.filePath) {
|
|
||||||
// No file yet — open to metadata so the panel is immediately useful.
|
|
||||||
panelOpen = true;
|
|
||||||
activeTab = 'metadata';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track last-visited document for the dashboard resume strip
|
|
||||||
if (doc?.id) {
|
if (doc?.id) {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'familienarchiv.lastVisited',
|
'familienarchiv.lastVisited',
|
||||||
@@ -103,11 +149,11 @@ onMount(() => {
|
|||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (activeAnnotationId) {
|
if (transcribeMode) {
|
||||||
|
transcribeMode = false;
|
||||||
|
} else if (activeAnnotationId) {
|
||||||
activeAnnotationId = null;
|
activeAnnotationId = null;
|
||||||
activeAnnotationPage = null;
|
activeAnnotationPage = null;
|
||||||
} else if (panelOpen) {
|
|
||||||
panelOpen = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,49 +173,54 @@ onMount(() => {
|
|||||||
>
|
>
|
||||||
<DocumentTopBar
|
<DocumentTopBar
|
||||||
doc={doc}
|
doc={doc}
|
||||||
canWrite={data.canWrite ?? false}
|
canWrite={canWrite}
|
||||||
canAnnotate={data.canAnnotate ?? false}
|
canAnnotate={data.canAnnotate ?? false}
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileUrl}
|
||||||
bind:annotateMode={annotateMode}
|
bind:annotateMode={annotateMode}
|
||||||
|
bind:transcribeMode={transcribeMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="relative flex-1 overflow-hidden">
|
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex' : ''}">
|
||||||
<DocumentViewer
|
<div class={transcribeMode ? 'relative flex-1 overflow-hidden' : 'absolute inset-0'}>
|
||||||
doc={doc}
|
<DocumentViewer
|
||||||
fileUrl={fileUrl}
|
doc={doc}
|
||||||
isLoading={isLoading}
|
fileUrl={fileUrl}
|
||||||
error={fileError}
|
isLoading={isLoading}
|
||||||
bind:annotateMode={annotateMode}
|
error={fileError}
|
||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:annotateMode={annotateMode}
|
||||||
bind:activeAnnotationPage={activeAnnotationPage}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
onAnnotationClick={(id) => {
|
bind:activeAnnotationPage={activeAnnotationPage}
|
||||||
activeAnnotationId = id;
|
onAnnotationClick={(id) => {
|
||||||
}}
|
activeAnnotationId = id;
|
||||||
/>
|
}}
|
||||||
<AnnotationSidePanel
|
/>
|
||||||
documentId={doc.id}
|
</div>
|
||||||
activeAnnotationId={activeAnnotationId}
|
|
||||||
activeAnnotationPage={activeAnnotationPage}
|
{#if !transcribeMode}
|
||||||
canComment={canComment}
|
<AnnotationSidePanel
|
||||||
currentUserId={currentUserId}
|
documentId={doc.id}
|
||||||
canAdmin={canAdmin}
|
activeAnnotationId={activeAnnotationId}
|
||||||
targetCommentId={targetAnnotationId ? targetCommentId : null}
|
activeAnnotationPage={activeAnnotationPage}
|
||||||
onClose={() => {
|
canComment={canComment}
|
||||||
activeAnnotationId = null;
|
currentUserId={currentUserId}
|
||||||
activeAnnotationPage = null;
|
canAdmin={canAdmin}
|
||||||
}}
|
targetCommentId={targetAnnotationId ? targetCommentId : null}
|
||||||
/>
|
onClose={() => {
|
||||||
|
activeAnnotationId = null;
|
||||||
|
activeAnnotationPage = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if transcribeMode}
|
||||||
|
<div class="w-[400px] shrink-0 border-l border-line lg:w-[480px]">
|
||||||
|
<TranscriptionEditView
|
||||||
|
blocks={transcriptionBlocks}
|
||||||
|
onBlockFocus={handleBlockFocus}
|
||||||
|
onSaveBlock={saveBlock}
|
||||||
|
onDeleteBlock={deleteBlock}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentBottomPanel
|
|
||||||
doc={doc}
|
|
||||||
comments={(data.comments ?? []) as never[]}
|
|
||||||
canComment={canComment}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
canAdmin={canAdmin}
|
|
||||||
targetCommentId={targetCommentId}
|
|
||||||
bind:open={panelOpen}
|
|
||||||
bind:height={panelHeight}
|
|
||||||
bind:activeTab={activeTab}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,17 +8,10 @@ import { createApiClient } from '$lib/api.server';
|
|||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
function makeCommentsResponse(comments: unknown[]) {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
json: vi.fn().mockResolvedValue(comments)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── happy path ───────────────────────────────────────────────────────────────
|
// ─── happy path ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('document detail load — happy path', () => {
|
describe('document detail load — happy path', () => {
|
||||||
it('returns document and comments on success', async () => {
|
it('returns document on success', async () => {
|
||||||
vi.mocked(createApiClient).mockReturnValue({
|
vi.mocked(createApiClient).mockReturnValue({
|
||||||
GET: vi.fn().mockResolvedValue({
|
GET: vi.fn().mockResolvedValue({
|
||||||
response: { ok: true, status: 200 },
|
response: { ok: true, status: 200 },
|
||||||
@@ -26,7 +19,7 @@ describe('document detail load — happy path', () => {
|
|||||||
})
|
})
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const mockFetch = vi.fn().mockResolvedValue(makeCommentsResponse([{ id: 'c1', body: 'Hi' }]));
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
params: { id: '123' },
|
params: { id: '123' },
|
||||||
@@ -34,45 +27,6 @@ describe('document detail load — happy path', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.document.title).toBe('Testbrief');
|
expect(result.document.title).toBe('Testbrief');
|
||||||
expect(result.comments).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty comments when the comments fetch fails', async () => {
|
|
||||||
vi.mocked(createApiClient).mockReturnValue({
|
|
||||||
GET: vi.fn().mockResolvedValue({
|
|
||||||
response: { ok: true, status: 200 },
|
|
||||||
data: { id: '123', title: 'Testbrief' }
|
|
||||||
})
|
|
||||||
} as ReturnType<typeof createApiClient>);
|
|
||||||
|
|
||||||
// fetch throws a network error for the comments endpoint
|
|
||||||
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'));
|
|
||||||
|
|
||||||
const result = await load({
|
|
||||||
params: { id: '123' },
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.document.title).toBe('Testbrief');
|
|
||||||
expect(result.comments).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty comments when the comments response is not ok', async () => {
|
|
||||||
vi.mocked(createApiClient).mockReturnValue({
|
|
||||||
GET: vi.fn().mockResolvedValue({
|
|
||||||
response: { ok: true, status: 200 },
|
|
||||||
data: { id: '123', title: 'Testbrief' }
|
|
||||||
})
|
|
||||||
} as ReturnType<typeof createApiClient>);
|
|
||||||
|
|
||||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
|
|
||||||
|
|
||||||
const result = await load({
|
|
||||||
params: { id: '123' },
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.comments).toEqual([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,7 +41,7 @@ describe('document detail load — error paths', () => {
|
|||||||
})
|
})
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
load({ params: { id: 'missing' }, fetch: mockFetch as unknown as typeof fetch })
|
load({ params: { id: 'missing' }, fetch: mockFetch as unknown as typeof fetch })
|
||||||
@@ -102,7 +56,7 @@ describe('document detail load — error paths', () => {
|
|||||||
})
|
})
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
load({ params: { id: 'secret' }, fetch: mockFetch as unknown as typeof fetch })
|
load({ params: { id: 'secret' }, fetch: mockFetch as unknown as typeof fetch })
|
||||||
@@ -117,7 +71,7 @@ describe('document detail load — error paths', () => {
|
|||||||
})
|
})
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
load({ params: { id: 'any' }, fetch: mockFetch as unknown as typeof fetch })
|
load({ params: { id: 'any' }, fetch: mockFetch as unknown as typeof fetch })
|
||||||
|
|||||||
Reference in New Issue
Block a user