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 */
|
||||
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;
|
||||
}
|
||||
@@ -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/paraglide/
|
||||
/src/lib/paraglide_bak*/
|
||||
/src/paraglide/
|
||||
|
||||
# Test artifacts
|
||||
/test-results/
|
||||
|
||||
@@ -423,5 +423,33 @@
|
||||
"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_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_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_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_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_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">
|
||||
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 AnnotateHintStrip from './AnnotateHintStrip.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;
|
||||
@@ -17,6 +20,9 @@ type Doc = {
|
||||
receivers?: Person[] | null;
|
||||
filePath?: string | null;
|
||||
contentType?: string | null;
|
||||
location?: string | null;
|
||||
status?: string | null;
|
||||
tags?: Tag[] | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -25,9 +31,19 @@ type Props = {
|
||||
canAnnotate: boolean;
|
||||
fileUrl: string;
|
||||
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 receivers = $derived(doc.receivers ?? []);
|
||||
@@ -84,6 +100,65 @@ let mobileMenuOpen = $state(false);
|
||||
</button>
|
||||
{/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)}
|
||||
<a
|
||||
href={fileUrl}
|
||||
@@ -155,12 +230,41 @@ let mobileMenuOpen = $state(false);
|
||||
<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 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 -->
|
||||
<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 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)}
|
||||
{/if}
|
||||
|
||||
@@ -168,7 +272,7 @@ let mobileMenuOpen = $state(false);
|
||||
{@render annotateStopBtn(false)}
|
||||
{/if}
|
||||
|
||||
{#if canWrite && !annotateMode}
|
||||
{#if canWrite && !annotateMode && !transcribeMode}
|
||||
<a
|
||||
href="/documents/{doc.id}/edit"
|
||||
aria-label={m.btn_edit()}
|
||||
@@ -217,7 +321,11 @@ let mobileMenuOpen = $state(false);
|
||||
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 canAnnotate && isPdf && !annotateMode}
|
||||
{#if canWrite && isPdf && !transcribeMode && !annotateMode}
|
||||
{@render transcribeBtn(true)}
|
||||
{/if}
|
||||
|
||||
{#if canAnnotate && isPdf && !annotateMode && !transcribeMode}
|
||||
{@render annotateBtn(true)}
|
||||
{/if}
|
||||
|
||||
@@ -233,4 +341,18 @@ let mobileMenuOpen = $state(false);
|
||||
|
||||
<!-- Hint strip — only when annotateMode, only at ≥768px -->
|
||||
<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>
|
||||
|
||||
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'
|
||||
| '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':
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
export async function load({ params, fetch }) {
|
||||
const { id } = params;
|
||||
const api = createApiClient(fetch);
|
||||
const base = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
|
||||
const [docResult, commentsRes] = await Promise.all([
|
||||
api.GET('/api/documents/{id}', { params: { path: { id } } }),
|
||||
fetch(`${base}/api/documents/${id}/comments`).catch(() => null)
|
||||
]);
|
||||
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id } } });
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
let comments: unknown[] = [];
|
||||
if (commentsRes?.ok) {
|
||||
try {
|
||||
comments = await commentsRes.json();
|
||||
} catch {
|
||||
// ignore invalid response
|
||||
}
|
||||
}
|
||||
|
||||
return { document: docResult.data!, comments };
|
||||
return { document: docResult.data! };
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
||||
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
|
||||
import type { DocumentPanelTab } from '$lib/types';
|
||||
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -13,6 +12,7 @@ const targetCommentId = $derived(page.url.searchParams.get('commentId'));
|
||||
const targetAnnotationId = $derived(page.url.searchParams.get('annotationId'));
|
||||
|
||||
const doc = $derived(data.document);
|
||||
const canWrite = $derived(data.canWrite ?? false);
|
||||
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
||||
const canAdmin = $derived(
|
||||
(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 transcribeMode = $state(false);
|
||||
let activeAnnotationId = $state<string | 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(() => {
|
||||
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 activeTab = $state<DocumentPanelTab>('metadata');
|
||||
|
||||
onMount(() => {
|
||||
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
||||
|
||||
const topbar = document.querySelector('[data-topbar]');
|
||||
panelHeight = window.innerHeight - navHeight - (topbar?.getBoundingClientRect().height ?? 0);
|
||||
|
||||
if (targetAnnotationId) {
|
||||
// Deep-link into an annotation comment: open the side panel
|
||||
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) {
|
||||
localStorage.setItem(
|
||||
'familienarchiv.lastVisited',
|
||||
@@ -103,11 +149,11 @@ onMount(() => {
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (activeAnnotationId) {
|
||||
if (transcribeMode) {
|
||||
transcribeMode = false;
|
||||
} else if (activeAnnotationId) {
|
||||
activeAnnotationId = null;
|
||||
activeAnnotationPage = null;
|
||||
} else if (panelOpen) {
|
||||
panelOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,49 +173,54 @@ onMount(() => {
|
||||
>
|
||||
<DocumentTopBar
|
||||
doc={doc}
|
||||
canWrite={data.canWrite ?? false}
|
||||
canWrite={canWrite}
|
||||
canAnnotate={data.canAnnotate ?? false}
|
||||
fileUrl={fileUrl}
|
||||
bind:annotateMode={annotateMode}
|
||||
bind:transcribeMode={transcribeMode}
|
||||
/>
|
||||
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
<DocumentViewer
|
||||
doc={doc}
|
||||
fileUrl={fileUrl}
|
||||
isLoading={isLoading}
|
||||
error={fileError}
|
||||
bind:annotateMode={annotateMode}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
bind:activeAnnotationPage={activeAnnotationPage}
|
||||
onAnnotationClick={(id) => {
|
||||
activeAnnotationId = id;
|
||||
}}
|
||||
/>
|
||||
<AnnotationSidePanel
|
||||
documentId={doc.id}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
activeAnnotationPage={activeAnnotationPage}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
targetCommentId={targetAnnotationId ? targetCommentId : null}
|
||||
onClose={() => {
|
||||
activeAnnotationId = null;
|
||||
activeAnnotationPage = null;
|
||||
}}
|
||||
/>
|
||||
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex' : ''}">
|
||||
<div class={transcribeMode ? 'relative flex-1 overflow-hidden' : 'absolute inset-0'}>
|
||||
<DocumentViewer
|
||||
doc={doc}
|
||||
fileUrl={fileUrl}
|
||||
isLoading={isLoading}
|
||||
error={fileError}
|
||||
bind:annotateMode={annotateMode}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
bind:activeAnnotationPage={activeAnnotationPage}
|
||||
onAnnotationClick={(id) => {
|
||||
activeAnnotationId = id;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if !transcribeMode}
|
||||
<AnnotationSidePanel
|
||||
documentId={doc.id}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
activeAnnotationPage={activeAnnotationPage}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -8,17 +8,10 @@ import { createApiClient } from '$lib/api.server';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeCommentsResponse(comments: unknown[]) {
|
||||
return {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(comments)
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 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({
|
||||
GET: vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
@@ -26,7 +19,7 @@ describe('document detail load — happy path', () => {
|
||||
})
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue(makeCommentsResponse([{ id: 'c1', body: 'Hi' }]));
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
const result = await load({
|
||||
params: { id: '123' },
|
||||
@@ -34,45 +27,6 @@ describe('document detail load — happy path', () => {
|
||||
});
|
||||
|
||||
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>);
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
await expect(
|
||||
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>);
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
await expect(
|
||||
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>);
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
await expect(
|
||||
load({ params: { id: 'any' }, fetch: mockFetch as unknown as typeof fetch })
|
||||
|
||||
Reference in New Issue
Block a user