Compare commits
1 Commits
feature/56
...
feat/62-do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c2bdbd777 |
@@ -41,10 +41,4 @@ public class AdminController {
|
|||||||
documentService.getDocumentsWithoutVersions());
|
documentService.getDocumentsWithoutVersions());
|
||||||
return ResponseEntity.ok(new BackfillResult(count));
|
return ResponseEntity.ok(new BackfillResult(count));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/backfill-file-hashes")
|
|
||||||
public ResponseEntity<BackfillResult> backfillFileHashes() {
|
|
||||||
int count = documentService.backfillFileHashes();
|
|
||||||
return ResponseEntity.ok(new BackfillResult(count));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
|
||||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.service.AnnotationService;
|
import org.raddatz.familienarchiv.service.AnnotationService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
@@ -25,7 +23,6 @@ import java.util.UUID;
|
|||||||
public class AnnotationController {
|
public class AnnotationController {
|
||||||
|
|
||||||
private final AnnotationService annotationService;
|
private final AnnotationService annotationService;
|
||||||
private final DocumentService documentService;
|
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -41,8 +38,7 @@ public class AnnotationController {
|
|||||||
@RequestBody CreateAnnotationDTO dto,
|
@RequestBody CreateAnnotationDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
UUID userId = resolveUserId(authentication);
|
UUID userId = resolveUserId(authentication);
|
||||||
Document doc = documentService.getDocumentById(documentId);
|
return annotationService.createAnnotation(documentId, dto, userId);
|
||||||
return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{annotationId}")
|
@DeleteMapping("/{annotationId}")
|
||||||
|
|||||||
@@ -39,10 +39,6 @@ public class Document {
|
|||||||
@Column(name = "content_type")
|
@Column(name = "content_type")
|
||||||
private String contentType;
|
private String contentType;
|
||||||
|
|
||||||
// SHA-256 hash of the uploaded file — used to link annotations to a file version
|
|
||||||
@Column(name = "file_hash", length = 64)
|
|
||||||
private String fileHash;
|
|
||||||
|
|
||||||
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
||||||
@Column(name = "original_filename", nullable = false)
|
@Column(name = "original_filename", nullable = false)
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
|||||||
@@ -49,9 +49,6 @@ public class DocumentAnnotation {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private String color;
|
private String color;
|
||||||
|
|
||||||
@Column(name = "file_hash", length = 64)
|
|
||||||
private String fileHash;
|
|
||||||
|
|
||||||
@Column(name = "created_by")
|
@Column(name = "created_by")
|
||||||
private UUID createdBy;
|
private UUID createdBy;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,4 @@ public interface AnnotationRepository extends JpaRepository<DocumentAnnotation,
|
|||||||
List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber);
|
List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber);
|
||||||
|
|
||||||
Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId);
|
Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId);
|
||||||
|
|
||||||
List<DocumentAnnotation> findByDocumentIdAndFileHashIsNull(UUID documentId);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
|
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
|
||||||
List<Document> findDocumentsWithoutVersions();
|
List<Document> findDocumentsWithoutVersions();
|
||||||
|
|
||||||
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
|
||||||
|
|
||||||
@Query("SELECT DISTINCT d FROM Document d " +
|
@Query("SELECT DISTINCT d FROM Document d " +
|
||||||
"JOIN d.receivers r " +
|
"JOIN d.receivers r " +
|
||||||
"WHERE " +
|
"WHERE " +
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public class AnnotationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) {
|
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId) {
|
||||||
List<DocumentAnnotation> existing =
|
List<DocumentAnnotation> existing =
|
||||||
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
|
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
|
||||||
|
|
||||||
@@ -41,7 +41,6 @@ public class AnnotationService {
|
|||||||
.width(dto.getWidth())
|
.width(dto.getWidth())
|
||||||
.height(dto.getHeight())
|
.height(dto.getHeight())
|
||||||
.color(dto.getColor())
|
.color(dto.getColor())
|
||||||
.fileHash(fileHash)
|
|
||||||
.createdBy(userId)
|
.createdBy(userId)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -62,14 +61,6 @@ public class AnnotationService {
|
|||||||
annotationRepository.delete(annotation);
|
annotationRepository.delete(annotation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void backfillAnnotationFileHashForDocument(UUID documentId, String fileHash) {
|
|
||||||
annotationRepository.findByDocumentIdAndFileHashIsNull(documentId).forEach(a -> {
|
|
||||||
a.setFileHash(fileHash);
|
|
||||||
annotationRepository.save(a);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) {
|
private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) {
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -40,7 +38,6 @@ public class DocumentService {
|
|||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
private final TagService tagService;
|
private final TagService tagService;
|
||||||
private final DocumentVersionService documentVersionService;
|
private final DocumentVersionService documentVersionService;
|
||||||
private final AnnotationService annotationService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt eine Datei hoch.
|
* Lädt eine Datei hoch.
|
||||||
@@ -67,11 +64,10 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Delegate Storage to FileService
|
// 2. Delegate Storage to FileService
|
||||||
FileService.UploadResult upload = fileService.uploadFile(file, originalFilename);
|
String s3Key = fileService.uploadFile(file, originalFilename);
|
||||||
|
|
||||||
// 3. Update Database
|
// 3. Update Database
|
||||||
document.setFilePath(upload.s3Key());
|
document.setFilePath(s3Key);
|
||||||
document.setFileHash(upload.fileHash());
|
|
||||||
document.setContentType(file.getContentType());
|
document.setContentType(file.getContentType());
|
||||||
if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
|
if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
|
||||||
document.setStatus(DocumentStatus.UPLOADED);
|
document.setStatus(DocumentStatus.UPLOADED);
|
||||||
@@ -124,9 +120,8 @@ public class DocumentService {
|
|||||||
|
|
||||||
// Datei
|
// Datei
|
||||||
if (file != null && !file.isEmpty()) {
|
if (file != null && !file.isEmpty()) {
|
||||||
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
|
String s3Key = fileService.uploadFile(file, file.getOriginalFilename());
|
||||||
doc.setFilePath(upload.s3Key());
|
doc.setFilePath(s3Key);
|
||||||
doc.setFileHash(upload.fileHash());
|
|
||||||
doc.setContentType(file.getContentType());
|
doc.setContentType(file.getContentType());
|
||||||
doc.setStatus(DocumentStatus.UPLOADED);
|
doc.setStatus(DocumentStatus.UPLOADED);
|
||||||
}
|
}
|
||||||
@@ -175,9 +170,12 @@ public class DocumentService {
|
|||||||
|
|
||||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
||||||
if (newFile != null && !newFile.isEmpty()) {
|
if (newFile != null && !newFile.isEmpty()) {
|
||||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
// Alte Datei könnte man hier theoretisch löschen (optional)
|
||||||
doc.setFilePath(upload.s3Key());
|
|
||||||
doc.setFileHash(upload.fileHash());
|
// Neue Datei hochladen
|
||||||
|
String s3Key = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||||
|
|
||||||
|
doc.setFilePath(s3Key);
|
||||||
doc.setOriginalFilename(newFile.getOriginalFilename());
|
doc.setOriginalFilename(newFile.getOriginalFilename());
|
||||||
doc.setContentType(newFile.getContentType());
|
doc.setContentType(newFile.getContentType());
|
||||||
doc.setStatus(DocumentStatus.UPLOADED);
|
doc.setStatus(DocumentStatus.UPLOADED);
|
||||||
@@ -285,39 +283,4 @@ public class DocumentService {
|
|||||||
});
|
});
|
||||||
tagService.delete(tagId);
|
tagService.delete(tagId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public int backfillFileHashes() {
|
|
||||||
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
|
|
||||||
int count = 0;
|
|
||||||
for (Document doc : docs) {
|
|
||||||
try {
|
|
||||||
byte[] bytes = fileService.downloadFileBytes(doc.getFilePath());
|
|
||||||
String hash = sha256Hex(bytes);
|
|
||||||
doc.setFileHash(hash);
|
|
||||||
documentRepository.save(doc);
|
|
||||||
annotationService.backfillAnnotationFileHashForDocument(doc.getId(), hash);
|
|
||||||
count++;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to backfill hash for document {}: {}", doc.getId(), e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static String sha256Hex(byte[] bytes) {
|
|
||||||
try {
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
||||||
byte[] hash = digest.digest(bytes);
|
|
||||||
StringBuilder sb = new StringBuilder(64);
|
|
||||||
for (byte b : hash) {
|
|
||||||
sb.append(String.format("%02x", b));
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
throw new IllegalStateException("SHA-256 not available", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import org.springframework.core.io.InputStreamResource;
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -32,14 +29,10 @@ public class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads a file to S3/MinIO.
|
* Uploads a file to S3/MinIO and returns the generated object key.
|
||||||
* Returns an {@link UploadResult} containing the S3 key and the SHA-256
|
|
||||||
* hash of the file content. The hash is used to link annotations to the
|
|
||||||
* specific file version they were created against.
|
|
||||||
*/
|
*/
|
||||||
public UploadResult uploadFile(MultipartFile file, String originalFilename) throws IOException {
|
public String uploadFile(MultipartFile file, String originalFilename) throws IOException {
|
||||||
byte[] bytes = file.getBytes();
|
// Generate secure unique path: "documents/UUID_filename"
|
||||||
String fileHash = sha256Hex(bytes);
|
|
||||||
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
|
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -49,10 +42,11 @@ public class FileService {
|
|||||||
.contentType(file.getContentType())
|
.contentType(file.getContentType())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes));
|
s3Client.putObject(putObjectRequest,
|
||||||
|
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
|
||||||
|
|
||||||
log.info("Uploaded file to S3: {} (hash={})", s3Key, fileHash);
|
log.info("Uploaded file to S3: {}", s3Key);
|
||||||
return new UploadResult(s3Key, fileHash);
|
return s3Key;
|
||||||
} catch (S3Exception e) {
|
} catch (S3Exception e) {
|
||||||
log.error("S3 Upload Error", e);
|
log.error("S3 Upload Error", e);
|
||||||
throw new IOException("Failed to upload file to storage", e);
|
throw new IOException("Failed to upload file to storage", e);
|
||||||
@@ -64,72 +58,32 @@ public class FileService {
|
|||||||
* Returns a wrapper containing the stream and content type.
|
* Returns a wrapper containing the stream and content type.
|
||||||
*/
|
*/
|
||||||
public S3FileDownload downloadFile(String s3Key) {
|
public S3FileDownload downloadFile(String s3Key) {
|
||||||
try {
|
try {
|
||||||
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
||||||
.bucket(bucketName)
|
.bucket(bucketName)
|
||||||
.key(s3Key)
|
.key(s3Key)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
|
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
|
||||||
|
|
||||||
String contentType = s3Object.response().contentType();
|
// Use whatever content type S3 has stored (set at upload time)
|
||||||
if (contentType == null || contentType.isBlank()) {
|
String contentType = s3Object.response().contentType();
|
||||||
contentType = "application/octet-stream";
|
if (contentType == null || contentType.isBlank()) {
|
||||||
}
|
contentType = "application/octet-stream";
|
||||||
|
|
||||||
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
|
|
||||||
|
|
||||||
} catch (NoSuchKeyException e) {
|
|
||||||
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
|
||||||
} catch (S3Exception e) {
|
|
||||||
throw new RuntimeException("Storage Error: " + e.getMessage());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
|
||||||
|
|
||||||
|
} catch (NoSuchKeyException e) {
|
||||||
|
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
||||||
|
} catch (S3Exception e) {
|
||||||
|
throw new RuntimeException("Storage Error: " + e.getMessage());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/**
|
// Helper Record to carry the stream and metadata back to the controller
|
||||||
* Downloads a file from S3/MinIO and returns its raw bytes.
|
|
||||||
* Used for hash backfill — callers are responsible for not calling this on large files unnecessarily.
|
|
||||||
*/
|
|
||||||
public byte[] downloadFileBytes(String s3Key) throws IOException {
|
|
||||||
try {
|
|
||||||
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
|
||||||
.bucket(bucketName)
|
|
||||||
.key(s3Key)
|
|
||||||
.build();
|
|
||||||
try (InputStream in = s3Client.getObject(getObjectRequest)) {
|
|
||||||
return in.readAllBytes();
|
|
||||||
}
|
|
||||||
} catch (NoSuchKeyException e) {
|
|
||||||
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
|
||||||
} catch (S3Exception e) {
|
|
||||||
throw new IOException("Failed to download file from storage: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static String sha256Hex(byte[] bytes) {
|
|
||||||
try {
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
||||||
byte[] hash = digest.digest(bytes);
|
|
||||||
StringBuilder sb = new StringBuilder(64);
|
|
||||||
for (byte b : hash) {
|
|
||||||
sb.append(String.format("%02x", b));
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
throw new IllegalStateException("SHA-256 not available", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── result types ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Carries the S3 object key and the content hash back to the caller. */
|
|
||||||
public record UploadResult(String s3Key, String fileHash) {}
|
|
||||||
|
|
||||||
/** Carries the download stream and content type. */
|
|
||||||
public record S3FileDownload(InputStreamResource resource, String contentType) {}
|
public record S3FileDownload(InputStreamResource resource, String contentType) {}
|
||||||
|
|
||||||
|
// Custom Exception
|
||||||
public static class StorageFileNotFoundException extends RuntimeException {
|
public static class StorageFileNotFoundException extends RuntimeException {
|
||||||
public StorageFileNotFoundException(String message) { super(message); }
|
public StorageFileNotFoundException(String message) { super(message); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
-- Add content-based file hash to documents for annotation versioning
|
|
||||||
ALTER TABLE documents
|
|
||||||
ADD COLUMN file_hash VARCHAR(64);
|
|
||||||
|
|
||||||
-- Each annotation remembers which file version it was created against
|
|
||||||
ALTER TABLE document_annotations
|
|
||||||
ADD COLUMN file_hash VARCHAR(64);
|
|
||||||
@@ -58,29 +58,4 @@ class AdminControllerTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.count").value(1));
|
.andExpect(jsonPath("$.count").value(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── POST /api/admin/backfill-file-hashes ──────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(roles = "USER")
|
|
||||||
void backfillFileHashes_returns403_whenNotAdmin() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "ADMIN")
|
|
||||||
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
|
|
||||||
when(documentService.backfillFileHashes()).thenReturn(3);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.count").value(3));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
|
||||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.service.AnnotationService;
|
import org.raddatz.familienarchiv.service.AnnotationService;
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
@@ -38,7 +36,6 @@ class AnnotationControllerTest {
|
|||||||
@Autowired MockMvc mockMvc;
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
@MockitoBean AnnotationService annotationService;
|
@MockitoBean AnnotationService annotationService;
|
||||||
@MockitoBean DocumentService documentService;
|
|
||||||
@MockitoBean UserService userService;
|
@MockitoBean UserService userService;
|
||||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
@@ -88,8 +85,7 @@ class AnnotationControllerTest {
|
|||||||
DocumentAnnotation saved = DocumentAnnotation.builder()
|
DocumentAnnotation saved = DocumentAnnotation.builder()
|
||||||
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||||
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
|
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
|
||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(annotationService.createAnnotation(any(), any(), any())).thenReturn(saved);
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -101,8 +97,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void createAnnotation_returns409_whenOverlap() throws Exception {
|
void createAnnotation_returns409_whenOverlap() throws Exception {
|
||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(annotationService.createAnnotation(any(), any(), any()))
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any()))
|
|
||||||
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
|
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class AnnotationServiceTest {
|
|||||||
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
|
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
|
||||||
.thenReturn(List.of(existing));
|
.thenReturn(List.of(existing));
|
||||||
|
|
||||||
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null))
|
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ class AnnotationServiceTest {
|
|||||||
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
|
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
|
||||||
when(annotationRepository.save(any())).thenReturn(saved);
|
when(annotationRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
|
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
assertThat(result).isEqualTo(saved);
|
||||||
verify(annotationRepository).save(any());
|
verify(annotationRepository).save(any());
|
||||||
@@ -117,35 +117,6 @@ class AnnotationServiceTest {
|
|||||||
verify(annotationRepository).delete(annotation);
|
verify(annotationRepository).delete(annotation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void createAnnotation_setsFileHash_whenProvided() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
|
|
||||||
String fileHash = "abc123";
|
|
||||||
|
|
||||||
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
|
|
||||||
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, fileHash);
|
|
||||||
|
|
||||||
assertThat(result.getFileHash()).isEqualTo(fileHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createAnnotation_setsNullFileHash_whenNoneProvided() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
|
|
||||||
|
|
||||||
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
|
|
||||||
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
|
|
||||||
|
|
||||||
assertThat(result.getFileHash()).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── listAnnotations ──────────────────────────────────────────────────────
|
// ─── listAnnotations ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -157,30 +128,4 @@ class AnnotationServiceTest {
|
|||||||
|
|
||||||
assertThat(annotationService.listAnnotations(docId)).containsExactly(a);
|
assertThat(annotationService.listAnnotations(docId)).containsExactly(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── backfillAnnotationFileHashForDocument ────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void backfillAnnotationFileHashForDocument_setsHashOnAnnotationsWithNullHash() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
String hash = "abc123";
|
|
||||||
DocumentAnnotation a = DocumentAnnotation.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).build();
|
|
||||||
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of(a));
|
|
||||||
|
|
||||||
annotationService.backfillAnnotationFileHashForDocument(docId, hash);
|
|
||||||
|
|
||||||
assertThat(a.getFileHash()).isEqualTo(hash);
|
|
||||||
verify(annotationRepository).save(a);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void backfillAnnotationFileHashForDocument_doesNothingWhenNoAnnotations() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of());
|
|
||||||
|
|
||||||
annotationService.backfillAnnotationFileHashForDocument(docId, "hash");
|
|
||||||
|
|
||||||
verify(annotationRepository, never()).save(any());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import java.util.UUID;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -32,7 +31,6 @@ class DocumentServiceTest {
|
|||||||
@Mock FileService fileService;
|
@Mock FileService fileService;
|
||||||
@Mock TagService tagService;
|
@Mock TagService tagService;
|
||||||
@Mock DocumentVersionService documentVersionService;
|
@Mock DocumentVersionService documentVersionService;
|
||||||
@Mock AnnotationService annotationService;
|
|
||||||
@InjectMocks DocumentService documentService;
|
@InjectMocks DocumentService documentService;
|
||||||
|
|
||||||
// ─── getDocumentById ──────────────────────────────────────────────────────
|
// ─── getDocumentById ──────────────────────────────────────────────────────
|
||||||
@@ -137,48 +135,6 @@ class DocumentServiceTest {
|
|||||||
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
|
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── file hash propagation ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createDocument_setsFileHashFromUpload_whenFileProvided() throws Exception {
|
|
||||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
|
||||||
dto.setTitle("Doc");
|
|
||||||
org.springframework.mock.web.MockMultipartFile file =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
|
||||||
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan.pdf", "deadbeef");
|
|
||||||
|
|
||||||
Document savedDoc = Document.builder().id(UUID.randomUUID()).title("Doc")
|
|
||||||
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
|
|
||||||
when(documentRepository.save(any())).thenReturn(savedDoc);
|
|
||||||
when(documentRepository.findById(any())).thenReturn(Optional.of(savedDoc));
|
|
||||||
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
|
||||||
|
|
||||||
documentService.createDocument(dto, file);
|
|
||||||
|
|
||||||
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
|
|
||||||
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
|
||||||
assertThat(captor.getAllValues()).anySatisfy(d -> assertThat(d.getFileHash()).isEqualTo("deadbeef"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateDocument_setsFileHashFromUpload_whenNewFileProvided() throws Exception {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Document existing = Document.builder()
|
|
||||||
.id(id).title("Alt").originalFilename("old.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED).build();
|
|
||||||
org.springframework.mock.web.MockMultipartFile newFile =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{2});
|
|
||||||
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_new.pdf", "cafebabe");
|
|
||||||
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
|
||||||
when(documentRepository.save(any())).thenReturn(existing);
|
|
||||||
|
|
||||||
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile);
|
|
||||||
|
|
||||||
assertThat(existing.getFileHash()).isEqualTo("cafebabe");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── versioning ───────────────────────────────────────────────────────────
|
// ─── versioning ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -211,59 +167,4 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
verify(documentVersionService).recordVersion(any(Document.class));
|
verify(documentVersionService).recordVersion(any(Document.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── backfillFileHashes ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void backfillFileHashes_skipsDocumentsWithNoFilePath() throws Exception {
|
|
||||||
Document noFile = Document.builder().id(UUID.randomUUID()).build();
|
|
||||||
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of());
|
|
||||||
|
|
||||||
int count = documentService.backfillFileHashes();
|
|
||||||
|
|
||||||
assertThat(count).isZero();
|
|
||||||
verify(fileService, never()).downloadFileBytes(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void backfillFileHashes_computesHashAndSavesDocument() throws Exception {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
|
|
||||||
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
|
|
||||||
when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
documentService.backfillFileHashes();
|
|
||||||
|
|
||||||
assertThat(doc.getFileHash()).isNotNull().hasSize(64);
|
|
||||||
verify(documentRepository).save(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void backfillFileHashes_propagatesHashToAnnotations() throws Exception {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
|
|
||||||
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
|
|
||||||
when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
documentService.backfillFileHashes();
|
|
||||||
|
|
||||||
verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception {
|
|
||||||
UUID id1 = UUID.randomUUID();
|
|
||||||
UUID id2 = UUID.randomUUID();
|
|
||||||
Document doc1 = Document.builder().id(id1).filePath("documents/a.pdf").build();
|
|
||||||
Document doc2 = Document.builder().id(id2).filePath("documents/b.pdf").build();
|
|
||||||
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc1, doc2));
|
|
||||||
when(fileService.downloadFileBytes(any())).thenReturn(new byte[]{1});
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
int count = documentService.backfillFileHashes();
|
|
||||||
|
|
||||||
assertThat(count).isEqualTo(2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
|
||||||
import software.amazon.awssdk.core.sync.RequestBody;
|
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
|
||||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
class FileServiceTest {
|
|
||||||
|
|
||||||
private S3Client s3Client;
|
|
||||||
private FileService fileService;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
s3Client = mock(S3Client.class);
|
|
||||||
fileService = new FileService(s3Client, "test-bucket");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void uploadFile_returnsS3Key() throws IOException {
|
|
||||||
MockMultipartFile file = new MockMultipartFile(
|
|
||||||
"file", "test.pdf", "application/pdf", new byte[]{1, 2, 3});
|
|
||||||
|
|
||||||
FileService.UploadResult result = fileService.uploadFile(file, "test.pdf");
|
|
||||||
|
|
||||||
assertThat(result.s3Key()).startsWith("documents/");
|
|
||||||
assertThat(result.s3Key()).endsWith("_test.pdf");
|
|
||||||
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void uploadFile_returnsCorrectSha256FileHash() throws IOException, NoSuchAlgorithmException {
|
|
||||||
byte[] content = "hello pdf content".getBytes();
|
|
||||||
MockMultipartFile file = new MockMultipartFile(
|
|
||||||
"file", "doc.pdf", "application/pdf", content);
|
|
||||||
|
|
||||||
FileService.UploadResult result = fileService.uploadFile(file, "doc.pdf");
|
|
||||||
|
|
||||||
// Compute expected hash independently
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
||||||
byte[] hashBytes = digest.digest(content);
|
|
||||||
StringBuilder expected = new StringBuilder();
|
|
||||||
for (byte b : hashBytes) {
|
|
||||||
expected.append(String.format("%02x", b));
|
|
||||||
}
|
|
||||||
|
|
||||||
assertThat(result.fileHash()).isEqualTo(expected.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void uploadFile_differentContents_produceDifferentHashes() throws IOException {
|
|
||||||
MockMultipartFile file1 = new MockMultipartFile(
|
|
||||||
"f", "a.pdf", "application/pdf", new byte[]{1, 2, 3});
|
|
||||||
MockMultipartFile file2 = new MockMultipartFile(
|
|
||||||
"f", "b.pdf", "application/pdf", new byte[]{4, 5, 6});
|
|
||||||
|
|
||||||
FileService.UploadResult r1 = fileService.uploadFile(file1, "a.pdf");
|
|
||||||
FileService.UploadResult r2 = fileService.uploadFile(file2, "b.pdf");
|
|
||||||
|
|
||||||
assertThat(r1.fileHash()).isNotEqualTo(r2.fileHash());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void uploadFile_sameContents_produceSameHash() throws IOException {
|
|
||||||
byte[] content = new byte[]{10, 20, 30};
|
|
||||||
MockMultipartFile file1 = new MockMultipartFile("f", "x.pdf", "application/pdf", content);
|
|
||||||
MockMultipartFile file2 = new MockMultipartFile("f", "y.pdf", "application/pdf", content);
|
|
||||||
|
|
||||||
FileService.UploadResult r1 = fileService.uploadFile(file1, "x.pdf");
|
|
||||||
FileService.UploadResult r2 = fileService.uploadFile(file2, "y.pdf");
|
|
||||||
|
|
||||||
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -216,35 +216,3 @@ test.describe('Admin — tag management', () => {
|
|||||||
await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' });
|
await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── System tab — backfill file hashes ────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Admin system tab — backfill file hashes', () => {
|
|
||||||
test('admin triggers file hash backfill and sees success message', async ({ request, page }) => {
|
|
||||||
test.setTimeout(60_000);
|
|
||||||
|
|
||||||
// Create a document via API so there is at least one without a hash
|
|
||||||
const createRes = await request.post('/api/documents', {
|
|
||||||
multipart: { title: 'E2E Backfill Hash Test' }
|
|
||||||
});
|
|
||||||
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
|
||||||
|
|
||||||
await page.goto('/admin');
|
|
||||||
await page.waitForSelector('[data-hydrated]');
|
|
||||||
|
|
||||||
// Navigate to System tab
|
|
||||||
await page.getByRole('button', { name: /system/i }).click();
|
|
||||||
|
|
||||||
// Click the backfill hashes button
|
|
||||||
const btn = page.getByRole('button', { name: /datei-hashes berechnen/i });
|
|
||||||
await expect(btn).toBeVisible();
|
|
||||||
await btn.click();
|
|
||||||
|
|
||||||
// Success message must appear (count >= 0)
|
|
||||||
await expect(page.locator('text=/\\d+ Dokumente wurden aktualisiert/i')).toBeVisible({
|
|
||||||
timeout: 15000
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/e2e/admin-backfill-hashes.png' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
180
frontend/e2e/bottom-panel.spec.ts
Normal file
180
frontend/e2e/bottom-panel.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom panel E2E tests — issue #62.
|
||||||
|
* Verifies the new document detail layout: full-viewport viewer + floating bottom panel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let pdfDocHref: string;
|
||||||
|
let noFileDocHref: string;
|
||||||
|
|
||||||
|
test.describe('Document bottom panel', () => {
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
// Create a document with a PDF and a date for metadata tests.
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E Bottom Panel Test', documentDate: '1945-05-08' }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||||
|
const doc = await createRes.json();
|
||||||
|
|
||||||
|
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
documentDate: '1945-05-08',
|
||||||
|
transcription: 'Dies ist eine vollständige Transkription des Dokuments für den E2E-Test.',
|
||||||
|
file: {
|
||||||
|
name: 'minimal.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||||
|
pdfDocHref = `${baseURL}/documents/${doc.id}`;
|
||||||
|
|
||||||
|
// Create a document WITHOUT a file — panel should open to Metadaten by default.
|
||||||
|
const noFileRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E Bottom Panel No-File Test' }
|
||||||
|
});
|
||||||
|
if (!noFileRes.ok()) throw new Error(`Create no-file document failed: ${noFileRes.status()}`);
|
||||||
|
noFileDocHref = `${baseURL}/documents/${noFileRes.json().then ? (await noFileRes.json()).id : ''}`;
|
||||||
|
const noFileDoc = await noFileRes.json();
|
||||||
|
noFileDocHref = `${baseURL}/documents/${noFileDoc.id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bottom panel tab bar is visible and panel content is closed by default on a PDF document', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
// Clear localStorage to ensure no previous panel state.
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Tab bar must always be visible.
|
||||||
|
await expect(page.getByRole('button', { name: 'Metadaten' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Transkription' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Diskussion' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Verlauf' })).toBeVisible();
|
||||||
|
|
||||||
|
// Panel content must NOT be visible when closed.
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/bottom-panel-closed-default.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking Metadaten tab opens the panel and shows metadata content', async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Metadaten' }).click();
|
||||||
|
|
||||||
|
// Panel content becomes visible.
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||||
|
|
||||||
|
// Metadata section heading should be present.
|
||||||
|
await expect(page.getByText('Details', { exact: false })).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/bottom-panel-metadata.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking Transkription tab shows transcription text', async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText('Dies ist eine vollständige Transkription', { exact: false })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/bottom-panel-transcription.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking Diskussion tab shows the comment input', async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Diskussion' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||||
|
await expect(page.getByPlaceholder('Kommentar schreiben…')).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/bottom-panel-discussion.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking × close button collapses the panel content', async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Open the panel first.
|
||||||
|
await page.getByRole('button', { name: 'Metadaten' }).click();
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||||
|
|
||||||
|
// Close it.
|
||||||
|
await page.locator('[data-testid="panel-close-btn"]').click();
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Tab bar still visible after closing.
|
||||||
|
await expect(page.getByRole('button', { name: 'Metadaten' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/bottom-panel-closed-after-x.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('panel open state persists after page reload', async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Open the panel to Diskussion.
|
||||||
|
await page.getByRole('button', { name: 'Diskussion' }).click();
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||||
|
|
||||||
|
// Reload — panel should re-open on the same tab.
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||||
|
await expect(page.getByPlaceholder('Kommentar schreiben…')).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/bottom-panel-persisted.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document without a file opens panel to Metadaten by default', async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await page.goto(noFileDocHref);
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Panel should be open to Metadaten by default when there is no file.
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||||
|
await expect(page.getByText('Details', { exact: false })).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/bottom-panel-no-file-default.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -347,142 +347,13 @@ test.describe('PDF annotations — admin', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── PDF Annotations — file hash (version awareness) ─────────────────────────
|
|
||||||
|
|
||||||
test.describe('PDF annotations — file hash versioning', () => {
|
|
||||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
||||||
const PDF_FIXTURE2 = path.resolve(__dirname, 'fixtures/minimal2.pdf');
|
|
||||||
|
|
||||||
test('annotations are hidden after a different file is uploaded', async ({ page, request }) => {
|
|
||||||
test.setTimeout(90_000);
|
|
||||||
|
|
||||||
// 1. Create document and upload original PDF
|
|
||||||
const createRes = await request.post('/api/documents', {
|
|
||||||
multipart: { title: 'E2E Hash Test — version' }
|
|
||||||
});
|
|
||||||
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
|
||||||
const doc = await createRes.json();
|
|
||||||
|
|
||||||
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
|
||||||
multipart: {
|
|
||||||
title: doc.title,
|
|
||||||
file: {
|
|
||||||
name: 'minimal.pdf',
|
|
||||||
mimeType: 'application/pdf',
|
|
||||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
|
|
||||||
|
|
||||||
// 2. Create an annotation via API
|
|
||||||
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
|
|
||||||
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#ff0000' }
|
|
||||||
});
|
|
||||||
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
|
|
||||||
|
|
||||||
// 3. Verify annotation appears before re-upload
|
|
||||||
await page.goto(`${baseURL}/documents/${doc.id}`);
|
|
||||||
await page.waitForSelector('[data-hydrated]');
|
|
||||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
|
||||||
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
|
||||||
timeout: 8000
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Upload a different file (different hash)
|
|
||||||
const reuploadRes = await request.put(`/api/documents/${doc.id}`, {
|
|
||||||
multipart: {
|
|
||||||
title: doc.title,
|
|
||||||
file: {
|
|
||||||
name: 'minimal2.pdf',
|
|
||||||
mimeType: 'application/pdf',
|
|
||||||
buffer: fs.readFileSync(PDF_FIXTURE2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!reuploadRes.ok()) throw new Error(`Re-upload failed: ${reuploadRes.status()}`);
|
|
||||||
|
|
||||||
// 5. Reload — annotation must be hidden and notice shown
|
|
||||||
await page.reload();
|
|
||||||
await page.waitForSelector('[data-hydrated]');
|
|
||||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
|
||||||
|
|
||||||
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, { timeout: 8000 });
|
|
||||||
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/e2e/annotation-hidden-after-reupload.png' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('annotations reappear after re-uploading the original file', async ({ page, request }) => {
|
|
||||||
test.setTimeout(90_000);
|
|
||||||
|
|
||||||
// 1. Create document and upload original PDF
|
|
||||||
const createRes = await request.post('/api/documents', {
|
|
||||||
multipart: { title: 'E2E Hash Test — restore' }
|
|
||||||
});
|
|
||||||
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
|
||||||
const doc = await createRes.json();
|
|
||||||
|
|
||||||
const originalBytes = fs.readFileSync(PDF_FIXTURE);
|
|
||||||
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
|
||||||
multipart: {
|
|
||||||
title: doc.title,
|
|
||||||
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
|
|
||||||
|
|
||||||
// 2. Create annotation
|
|
||||||
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
|
|
||||||
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#0000ff' }
|
|
||||||
});
|
|
||||||
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
|
|
||||||
|
|
||||||
// 3. Replace with different file
|
|
||||||
const replaceRes = await request.put(`/api/documents/${doc.id}`, {
|
|
||||||
multipart: {
|
|
||||||
title: doc.title,
|
|
||||||
file: {
|
|
||||||
name: 'minimal2.pdf',
|
|
||||||
mimeType: 'application/pdf',
|
|
||||||
buffer: fs.readFileSync(PDF_FIXTURE2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!replaceRes.ok()) throw new Error(`Replace failed: ${replaceRes.status()}`);
|
|
||||||
|
|
||||||
// 4. Re-upload original file (restoring the hash)
|
|
||||||
const restoreRes = await request.put(`/api/documents/${doc.id}`, {
|
|
||||||
multipart: {
|
|
||||||
title: doc.title,
|
|
||||||
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!restoreRes.ok()) throw new Error(`Restore failed: ${restoreRes.status()}`);
|
|
||||||
|
|
||||||
// 5. Verify annotation reappears and notice is gone
|
|
||||||
await page.goto(`${baseURL}/documents/${doc.id}`);
|
|
||||||
await page.waitForSelector('[data-hydrated]');
|
|
||||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
|
||||||
|
|
||||||
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
|
||||||
timeout: 8000
|
|
||||||
});
|
|
||||||
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).not.toBeVisible();
|
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/e2e/annotation-restored.png' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── PDF Annotations (read-only user) ─────────────────────────────────────────
|
// ─── PDF Annotations (read-only user) ─────────────────────────────────────────
|
||||||
|
|
||||||
test.describe('PDF annotations — read-only user', () => {
|
test.describe('PDF annotations — read-only user', () => {
|
||||||
// Isolated session — does not share the admin storage state
|
// Isolated session — does not share the admin storage state
|
||||||
test.use({ storageState: { cookies: [], origins: [] } });
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
test('read-only user does not see the Annotieren button', async ({ page }) => {
|
test('read-only user sees a disabled Annotieren button', async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByLabel('Benutzername').fill('reader');
|
await page.getByLabel('Benutzername').fill('reader');
|
||||||
@@ -494,10 +365,12 @@ test.describe('PDF annotations — read-only user', () => {
|
|||||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||||
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
|
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
|
||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
// Wait for the PDF canvas — once rendered, the controls bar (with disabled button) is shown.
|
||||||
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 30000 });
|
||||||
|
|
||||||
// Reader users do not have ANNOTATE_ALL permission — the button must not be shown at all.
|
const disabledBtn = page.getByRole('button', { name: /annotieren/i });
|
||||||
const annotateBtn = page.getByRole('button', { name: /annotieren/i });
|
await expect(disabledBtn).toBeVisible({ timeout: 5000 });
|
||||||
await expect(annotateBtn).not.toBeVisible({ timeout: 5000 });
|
await expect(disabledBtn).toBeDisabled();
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
|
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
%PDF-1.4
|
|
||||||
1 0 obj
|
|
||||||
<</Type/Catalog/Pages 2 0 R>>
|
|
||||||
endobj
|
|
||||||
2 0 obj
|
|
||||||
<</Type/Pages/Kids[3 0 R]/Count 1>>
|
|
||||||
endobj
|
|
||||||
3 0 obj
|
|
||||||
<</Type/Page/MediaBox[0 0 3 3]/Parent 2 0 R>>
|
|
||||||
endobj
|
|
||||||
xref
|
|
||||||
0 4
|
|
||||||
0000000000 65535 f
|
|
||||||
0000000009 00000 n
|
|
||||||
0000000058 00000 n
|
|
||||||
0000000115 00000 n
|
|
||||||
trailer
|
|
||||||
<</Size 4/Root 1 0 R>>
|
|
||||||
startxref
|
|
||||||
190
|
|
||||||
%%EOF
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"error_annotation_not_found": "Die Annotation wurde nicht gefunden.",
|
"error_annotation_not_found": "Die Annotation wurde nicht gefunden.",
|
||||||
"error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.",
|
"error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.",
|
||||||
"annotation_outdated_notice": "Einige Annotationen beziehen sich auf eine frühere Dateiversion und werden nicht angezeigt.",
|
|
||||||
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
|
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
|
||||||
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
||||||
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
||||||
@@ -242,10 +241,6 @@
|
|||||||
"admin_system_backfill_description": "Erstellt einen initialen Verlaufseintrag für alle Dokumente, die noch keinen Verlauf haben (z.B. importierte Dokumente). Dadurch werden beim nächsten Bearbeiten nur die tatsächlich geänderten Felder hervorgehoben.",
|
"admin_system_backfill_description": "Erstellt einen initialen Verlaufseintrag für alle Dokumente, die noch keinen Verlauf haben (z.B. importierte Dokumente). Dadurch werden beim nächsten Bearbeiten nur die tatsächlich geänderten Felder hervorgehoben.",
|
||||||
"admin_system_backfill_btn": "Jetzt auffüllen",
|
"admin_system_backfill_btn": "Jetzt auffüllen",
|
||||||
"admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.",
|
"admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.",
|
||||||
"admin_system_backfill_hashes_heading": "Datei-Hashes berechnen",
|
|
||||||
"admin_system_backfill_hashes_description": "Berechnet den SHA-256-Hash für alle bereits hochgeladenen Dokumente, die noch keinen Hash haben. Dadurch werden Annotationen korrekt mit ihrer Dateiversion verknüpft und wieder angezeigt.",
|
|
||||||
"admin_system_backfill_hashes_btn": "Datei-Hashes berechnen",
|
|
||||||
"admin_system_backfill_hashes_success": "{count} Dokumente wurden aktualisiert.",
|
|
||||||
"comp_expandable_show_more": "Mehr anzeigen",
|
"comp_expandable_show_more": "Mehr anzeigen",
|
||||||
"comp_expandable_show_less": "Weniger anzeigen",
|
"comp_expandable_show_less": "Weniger anzeigen",
|
||||||
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
|
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
|
||||||
@@ -255,5 +250,12 @@
|
|||||||
"comment_btn_reply": "Antworten",
|
"comment_btn_reply": "Antworten",
|
||||||
"comment_edited_label": "· bearbeitet",
|
"comment_edited_label": "· bearbeitet",
|
||||||
"comment_panel_title": "Kommentare",
|
"comment_panel_title": "Kommentare",
|
||||||
"comment_panel_close": "Schließen"
|
"comment_panel_close": "Schließen",
|
||||||
|
"doc_panel_tab_metadata": "Metadaten",
|
||||||
|
"doc_panel_tab_transcription": "Transkription",
|
||||||
|
"doc_panel_tab_discussion": "Diskussion",
|
||||||
|
"doc_panel_tab_history": "Verlauf",
|
||||||
|
"doc_panel_annotate": "Annotieren",
|
||||||
|
"doc_panel_annotate_stop": "Fertig",
|
||||||
|
"doc_panel_annotation_thread_title": "Annotation"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"error_annotation_not_found": "Annotation not found.",
|
"error_annotation_not_found": "Annotation not found.",
|
||||||
"error_annotation_overlap": "The annotation overlaps an existing one.",
|
"error_annotation_overlap": "The annotation overlaps an existing one.",
|
||||||
"annotation_outdated_notice": "Some annotations refer to an earlier file version and are not shown.",
|
|
||||||
"error_document_not_found": "Document not found.",
|
"error_document_not_found": "Document not found.",
|
||||||
"error_document_no_file": "No file is associated with this document.",
|
"error_document_no_file": "No file is associated with this document.",
|
||||||
"error_file_not_found": "The file could not be found in storage.",
|
"error_file_not_found": "The file could not be found in storage.",
|
||||||
@@ -242,10 +241,6 @@
|
|||||||
"admin_system_backfill_description": "Creates an initial history entry for all documents that do not have one yet (e.g. imported documents). This ensures that future edits only highlight actually changed fields.",
|
"admin_system_backfill_description": "Creates an initial history entry for all documents that do not have one yet (e.g. imported documents). This ensures that future edits only highlight actually changed fields.",
|
||||||
"admin_system_backfill_btn": "Backfill now",
|
"admin_system_backfill_btn": "Backfill now",
|
||||||
"admin_system_backfill_success": "{count} documents were backfilled.",
|
"admin_system_backfill_success": "{count} documents were backfilled.",
|
||||||
"admin_system_backfill_hashes_heading": "Compute file hashes",
|
|
||||||
"admin_system_backfill_hashes_description": "Computes the SHA-256 hash for all previously uploaded documents that do not have one yet. This ensures annotations are correctly linked to their file version and shown again.",
|
|
||||||
"admin_system_backfill_hashes_btn": "Compute file hashes",
|
|
||||||
"admin_system_backfill_hashes_success": "{count} documents were updated.",
|
|
||||||
"comp_expandable_show_more": "Show more",
|
"comp_expandable_show_more": "Show more",
|
||||||
"comp_expandable_show_less": "Show less",
|
"comp_expandable_show_less": "Show less",
|
||||||
"error_comment_not_found": "The comment could not be found.",
|
"error_comment_not_found": "The comment could not be found.",
|
||||||
@@ -255,5 +250,12 @@
|
|||||||
"comment_btn_reply": "Reply",
|
"comment_btn_reply": "Reply",
|
||||||
"comment_edited_label": "· edited",
|
"comment_edited_label": "· edited",
|
||||||
"comment_panel_title": "Comments",
|
"comment_panel_title": "Comments",
|
||||||
"comment_panel_close": "Close"
|
"comment_panel_close": "Close",
|
||||||
|
"doc_panel_tab_metadata": "Metadata",
|
||||||
|
"doc_panel_tab_transcription": "Transcription",
|
||||||
|
"doc_panel_tab_discussion": "Discussion",
|
||||||
|
"doc_panel_tab_history": "History",
|
||||||
|
"doc_panel_annotate": "Annotate",
|
||||||
|
"doc_panel_annotate_stop": "Done",
|
||||||
|
"doc_panel_annotation_thread_title": "Annotation"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"error_annotation_not_found": "Anotación no encontrada.",
|
"error_annotation_not_found": "Anotación no encontrada.",
|
||||||
"error_annotation_overlap": "La anotación se superpone con una existente.",
|
"error_annotation_overlap": "La anotación se superpone con una existente.",
|
||||||
"annotation_outdated_notice": "Algunas anotaciones hacen referencia a una versión anterior del archivo y no se muestran.",
|
|
||||||
"error_document_not_found": "Documento no encontrado.",
|
"error_document_not_found": "Documento no encontrado.",
|
||||||
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
||||||
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
||||||
@@ -242,10 +241,6 @@
|
|||||||
"admin_system_backfill_description": "Crea una entrada de historial inicial para todos los documentos que aún no tienen ninguna (p.ej. documentos importados). Así, en la próxima edición solo se resaltarán los campos realmente modificados.",
|
"admin_system_backfill_description": "Crea una entrada de historial inicial para todos los documentos que aún no tienen ninguna (p.ej. documentos importados). Así, en la próxima edición solo se resaltarán los campos realmente modificados.",
|
||||||
"admin_system_backfill_btn": "Completar ahora",
|
"admin_system_backfill_btn": "Completar ahora",
|
||||||
"admin_system_backfill_success": "{count} documentos fueron completados.",
|
"admin_system_backfill_success": "{count} documentos fueron completados.",
|
||||||
"admin_system_backfill_hashes_heading": "Calcular hashes de archivo",
|
|
||||||
"admin_system_backfill_hashes_description": "Calcula el hash SHA-256 para todos los documentos ya subidos que aún no tienen uno. Así las anotaciones se vinculan correctamente a su versión del archivo y vuelven a mostrarse.",
|
|
||||||
"admin_system_backfill_hashes_btn": "Calcular hashes de archivo",
|
|
||||||
"admin_system_backfill_hashes_success": "{count} documentos fueron actualizados.",
|
|
||||||
"comp_expandable_show_more": "Mostrar más",
|
"comp_expandable_show_more": "Mostrar más",
|
||||||
"comp_expandable_show_less": "Mostrar menos",
|
"comp_expandable_show_less": "Mostrar menos",
|
||||||
"error_comment_not_found": "El comentario no pudo encontrarse.",
|
"error_comment_not_found": "El comentario no pudo encontrarse.",
|
||||||
@@ -255,5 +250,12 @@
|
|||||||
"comment_btn_reply": "Responder",
|
"comment_btn_reply": "Responder",
|
||||||
"comment_edited_label": "· editado",
|
"comment_edited_label": "· editado",
|
||||||
"comment_panel_title": "Comentarios",
|
"comment_panel_title": "Comentarios",
|
||||||
"comment_panel_close": "Cerrar"
|
"comment_panel_close": "Cerrar",
|
||||||
|
"doc_panel_tab_metadata": "Metadatos",
|
||||||
|
"doc_panel_tab_transcription": "Transcripción",
|
||||||
|
"doc_panel_tab_discussion": "Discusión",
|
||||||
|
"doc_panel_tab_history": "Historial",
|
||||||
|
"doc_panel_annotate": "Anotar",
|
||||||
|
"doc_panel_annotate_stop": "Listo",
|
||||||
|
"doc_panel_annotation_thread_title": "Anotación"
|
||||||
}
|
}
|
||||||
|
|||||||
196
frontend/src/lib/components/DocumentBottomPanel.svelte
Normal file
196
frontend/src/lib/components/DocumentBottomPanel.svelte
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import PanelMetadata from './PanelMetadata.svelte';
|
||||||
|
import PanelTranscription from './PanelTranscription.svelte';
|
||||||
|
import PanelDiscussion from './PanelDiscussion.svelte';
|
||||||
|
import PanelHistory from './PanelHistory.svelte';
|
||||||
|
|
||||||
|
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||||
|
|
||||||
|
type CommentReply = {
|
||||||
|
id: string;
|
||||||
|
authorId: string | null;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Comment = {
|
||||||
|
id: string;
|
||||||
|
authorId: string | null;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
replies: CommentReply[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Doc = {
|
||||||
|
id: string;
|
||||||
|
title?: string | null;
|
||||||
|
documentDate?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
documentLocation?: string | null;
|
||||||
|
tags?: { id: string; name: string }[] | null;
|
||||||
|
sender?: { id: string; firstName: string; lastName: string; alias?: string | null } | null;
|
||||||
|
receivers?: { id: string; firstName: string; lastName: string }[] | null;
|
||||||
|
summary?: string | null;
|
||||||
|
transcription?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
doc: Doc;
|
||||||
|
comments: Comment[];
|
||||||
|
canComment: boolean;
|
||||||
|
currentUserId: string | null;
|
||||||
|
canAdmin: boolean;
|
||||||
|
open: boolean;
|
||||||
|
height: number;
|
||||||
|
activeTab: Tab;
|
||||||
|
activeAnnotationId: string | null;
|
||||||
|
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
doc,
|
||||||
|
comments,
|
||||||
|
canComment,
|
||||||
|
currentUserId,
|
||||||
|
canAdmin,
|
||||||
|
open = $bindable(),
|
||||||
|
height = $bindable(),
|
||||||
|
activeTab = $bindable(),
|
||||||
|
activeAnnotationId,
|
||||||
|
onAnnotationCommentCountChange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
|
||||||
|
const DEFAULT_HEIGHT = 320;
|
||||||
|
|
||||||
|
let isDragging = $state(false);
|
||||||
|
let dragStartY = 0;
|
||||||
|
let dragStartHeight = 0;
|
||||||
|
|
||||||
|
function openTab(tab: Tab) {
|
||||||
|
activeTab = tab;
|
||||||
|
if (!open) {
|
||||||
|
open = true;
|
||||||
|
if (height <= MIN_HEIGHT) height = DEFAULT_HEIGHT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragStart(e: PointerEvent) {
|
||||||
|
isDragging = true;
|
||||||
|
dragStartY = e.clientY;
|
||||||
|
dragStartHeight = open ? height : MIN_HEIGHT;
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragMove(e: PointerEvent) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
|
||||||
|
const newHeight = dragStartHeight + delta;
|
||||||
|
const maxHeight = Math.floor(window.innerHeight * 0.8);
|
||||||
|
|
||||||
|
if (newHeight <= MIN_HEIGHT + 20) {
|
||||||
|
// collapsed past threshold → close
|
||||||
|
open = false;
|
||||||
|
} else {
|
||||||
|
open = true;
|
||||||
|
height = Math.max(DEFAULT_HEIGHT / 4, Math.min(newHeight, maxHeight));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: { id: Tab; label: () => string }[] = [
|
||||||
|
{ id: 'metadata', label: m.doc_panel_tab_metadata },
|
||||||
|
{ id: 'transcription', label: m.doc_panel_tab_transcription },
|
||||||
|
{ id: 'discussion', label: m.doc_panel_tab_discussion },
|
||||||
|
{ id: 'history', label: m.doc_panel_tab_history }
|
||||||
|
];
|
||||||
|
|
||||||
|
const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed right-0 bottom-0 left-0 z-30 flex flex-col border-t border-brand-sand bg-white shadow-[0_-4px_16px_rgba(0,0,0,0.08)]"
|
||||||
|
style="height: {panelHeight}px"
|
||||||
|
data-testid="bottom-panel"
|
||||||
|
>
|
||||||
|
<!-- Drag handle -->
|
||||||
|
<div
|
||||||
|
class="flex h-2 shrink-0 cursor-ns-resize items-center justify-center bg-white"
|
||||||
|
style="touch-action: none"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-label="Panel resize"
|
||||||
|
onpointerdown={onDragStart}
|
||||||
|
onpointermove={onDragMove}
|
||||||
|
onpointerup={onDragEnd}
|
||||||
|
onpointercancel={onDragEnd}
|
||||||
|
>
|
||||||
|
<div class="h-1 w-12 rounded-full bg-gray-200"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<div class="flex shrink-0 items-center border-b border-brand-sand bg-white px-4">
|
||||||
|
{#each tabs as tab (tab.id)}
|
||||||
|
<button
|
||||||
|
onclick={() => openTab(tab.id)}
|
||||||
|
class="mr-1 px-3 py-2.5 font-sans text-xs font-medium transition-colors {activeTab === tab.id && open
|
||||||
|
? 'border-b-2 border-brand-navy text-brand-navy'
|
||||||
|
: 'text-gray-400 hover:text-brand-navy'}"
|
||||||
|
aria-pressed={activeTab === tab.id && open}
|
||||||
|
>
|
||||||
|
{tab.label()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- spacer -->
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<button
|
||||||
|
onclick={closePanel}
|
||||||
|
data-testid="panel-close-btn"
|
||||||
|
aria-label="Panel schließen"
|
||||||
|
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab content -->
|
||||||
|
{#if open}
|
||||||
|
<div class="flex-1 overflow-y-auto" data-testid="bottom-panel-content">
|
||||||
|
{#if activeTab === 'metadata'}
|
||||||
|
<PanelMetadata doc={doc} />
|
||||||
|
{:else if activeTab === 'transcription'}
|
||||||
|
<PanelTranscription doc={doc} />
|
||||||
|
{:else if activeTab === 'discussion'}
|
||||||
|
<PanelDiscussion
|
||||||
|
documentId={doc.id}
|
||||||
|
activeAnnotationId={activeAnnotationId}
|
||||||
|
initialComments={comments}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
onAnnotationCommentCountChange={onAnnotationCommentCountChange}
|
||||||
|
/>
|
||||||
|
{:else if activeTab === 'history'}
|
||||||
|
<PanelHistory documentId={doc.id} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
142
frontend/src/lib/components/DocumentTopBar.svelte
Normal file
142
frontend/src/lib/components/DocumentTopBar.svelte
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type Person = { id: string; firstName: string; lastName: string };
|
||||||
|
|
||||||
|
type Doc = {
|
||||||
|
id: string;
|
||||||
|
title?: string | null;
|
||||||
|
originalFilename?: string | null;
|
||||||
|
documentDate?: string | null;
|
||||||
|
sender?: Person | null;
|
||||||
|
receivers?: Person[] | null;
|
||||||
|
filePath?: string | null;
|
||||||
|
contentType?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
doc: Doc;
|
||||||
|
canWrite: boolean;
|
||||||
|
canAnnotate: boolean;
|
||||||
|
fileUrl: string;
|
||||||
|
annotateMode: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { doc, canWrite, canAnnotate, fileUrl, annotateMode = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('application/pdf'));
|
||||||
|
|
||||||
|
const receiverDisplay = $derived.by(() => {
|
||||||
|
const receivers = doc.receivers ?? [];
|
||||||
|
if (receivers.length === 0) return null;
|
||||||
|
const shown = receivers.slice(0, 2);
|
||||||
|
const extra = receivers.length - shown.length;
|
||||||
|
const names = shown.map((r) => `${r.firstName} ${r.lastName}`).join(', ');
|
||||||
|
return extra > 0 ? `${names} +${extra}` : names;
|
||||||
|
});
|
||||||
|
|
||||||
|
const compactMeta = $derived.by(() => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (doc.documentDate) {
|
||||||
|
parts.push(
|
||||||
|
new Intl.DateTimeFormat('de-DE', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
}).format(new Date(doc.documentDate + 'T12:00:00'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (doc.sender) {
|
||||||
|
const senderName = `${doc.sender.firstName} ${doc.sender.lastName}`;
|
||||||
|
const receiver = receiverDisplay;
|
||||||
|
parts.push(receiver ? `${senderName} → ${receiver}` : senderName);
|
||||||
|
} else if (receiverDisplay) {
|
||||||
|
parts.push(`→ ${receiverDisplay}`);
|
||||||
|
}
|
||||||
|
return parts.join(' · ');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="z-20 flex shrink-0 items-center justify-between border-b border-brand-sand bg-white px-6 py-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<!-- Left: back + title -->
|
||||||
|
<div class="flex min-w-0 items-center gap-4 overflow-hidden">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="group flex shrink-0 items-center gap-2 font-sans text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-sand transition-colors group-hover:bg-brand-mint"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="hidden sm:inline">{m.btn_back()}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="min-w-0 border-l border-gray-200 pl-4">
|
||||||
|
<h1
|
||||||
|
class="truncate font-serif text-base leading-tight text-brand-navy"
|
||||||
|
title={doc.title ?? doc.originalFilename ?? ''}
|
||||||
|
>
|
||||||
|
{doc.title || doc.originalFilename}
|
||||||
|
</h1>
|
||||||
|
{#if compactMeta}
|
||||||
|
<p class="truncate font-sans text-xs text-gray-500" title={compactMeta}>
|
||||||
|
{compactMeta}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: actions -->
|
||||||
|
<div class="ml-4 flex shrink-0 items-center gap-2 font-sans">
|
||||||
|
{#if canAnnotate && isPdf}
|
||||||
|
<button
|
||||||
|
onclick={() => (annotateMode = !annotateMode)}
|
||||||
|
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
||||||
|
class="rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
|
||||||
|
? 'bg-brand-navy text-white'
|
||||||
|
: 'border border-brand-navy text-brand-navy hover:bg-brand-navy hover:text-white'}"
|
||||||
|
>
|
||||||
|
{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if canWrite}
|
||||||
|
<a
|
||||||
|
href="/documents/{doc.id}/edit"
|
||||||
|
class="flex items-center gap-2 rounded border border-brand-navy bg-transparent px-3 py-1.5 text-xs font-medium text-brand-navy transition hover:bg-brand-navy hover:text-white"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{m.btn_edit()}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if doc.filePath}
|
||||||
|
<a
|
||||||
|
href={fileUrl}
|
||||||
|
download={doc.originalFilename}
|
||||||
|
class="rounded border border-transparent bg-brand-sand/50 p-1.5 text-brand-navy transition hover:bg-brand-mint"
|
||||||
|
title={m.doc_download_title()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
93
frontend/src/lib/components/DocumentViewer.svelte
Normal file
93
frontend/src/lib/components/DocumentViewer.svelte
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import PdfViewer from './PdfViewer.svelte';
|
||||||
|
|
||||||
|
type Doc = {
|
||||||
|
id: string;
|
||||||
|
filePath?: string | null;
|
||||||
|
contentType?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
doc: Doc;
|
||||||
|
fileUrl: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string;
|
||||||
|
annotateMode: boolean;
|
||||||
|
activeAnnotationId: string | null;
|
||||||
|
onAnnotationClick: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
doc,
|
||||||
|
fileUrl,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
annotateMode = $bindable(),
|
||||||
|
activeAnnotationId = $bindable(),
|
||||||
|
onAnnotationClick
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="absolute inset-0 bg-[#2A2A2A]">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex h-full flex-col items-center justify-center text-brand-mint">
|
||||||
|
<svg
|
||||||
|
class="mb-4 h-8 w-8 animate-spin"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<span class="font-sans text-sm tracking-wide">{m.doc_loading()}</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex h-full flex-col items-center justify-center px-4 text-center text-gray-400">
|
||||||
|
<p class="mb-2 font-serif">{error}</p>
|
||||||
|
{#if doc.filePath}
|
||||||
|
<a
|
||||||
|
href="/api/documents/{doc.id}/file"
|
||||||
|
target="_blank"
|
||||||
|
class="text-sm underline hover:text-white"
|
||||||
|
>
|
||||||
|
{m.doc_download_link()}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if !doc.filePath}
|
||||||
|
<div class="flex h-full flex-col items-center justify-center text-gray-400">
|
||||||
|
<div class="mb-6 rounded-full bg-white/5 p-8">
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-12 w-12 opacity-50 invert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
||||||
|
</div>
|
||||||
|
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
|
||||||
|
<PdfViewer
|
||||||
|
url={fileUrl}
|
||||||
|
documentId={doc.id}
|
||||||
|
bind:annotateMode={annotateMode}
|
||||||
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
|
onAnnotationClick={onAnnotationClick}
|
||||||
|
/>
|
||||||
|
{:else if fileUrl}
|
||||||
|
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
||||||
|
<img
|
||||||
|
src={fileUrl}
|
||||||
|
alt={m.doc_image_alt()}
|
||||||
|
class="max-h-full max-w-full object-contain shadow-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
85
frontend/src/lib/components/PanelDiscussion.svelte
Normal file
85
frontend/src/lib/components/PanelDiscussion.svelte
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import CommentThread from './CommentThread.svelte';
|
||||||
|
|
||||||
|
type CommentReply = {
|
||||||
|
id: string;
|
||||||
|
authorId: string | null;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Comment = {
|
||||||
|
id: string;
|
||||||
|
authorId: string | null;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
replies: CommentReply[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
documentId: string;
|
||||||
|
activeAnnotationId: string | null;
|
||||||
|
initialComments: Comment[];
|
||||||
|
canComment: boolean;
|
||||||
|
currentUserId: string | null;
|
||||||
|
canAdmin: boolean;
|
||||||
|
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
documentId,
|
||||||
|
activeAnnotationId,
|
||||||
|
initialComments,
|
||||||
|
canComment,
|
||||||
|
currentUserId,
|
||||||
|
canAdmin,
|
||||||
|
onAnnotationCommentCountChange
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-8 p-6">
|
||||||
|
<!-- Annotation thread (shown when an annotation is active) -->
|
||||||
|
{#if activeAnnotationId}
|
||||||
|
<div>
|
||||||
|
<h4
|
||||||
|
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||||
|
>
|
||||||
|
{m.doc_panel_annotation_thread_title()}
|
||||||
|
</h4>
|
||||||
|
{#key activeAnnotationId}
|
||||||
|
<CommentThread
|
||||||
|
documentId={documentId}
|
||||||
|
annotationId={activeAnnotationId}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
loadOnMount={true}
|
||||||
|
onCountChange={(count) => onAnnotationCommentCountChange?.(activeAnnotationId, count)}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- General document discussion -->
|
||||||
|
<div>
|
||||||
|
{#if activeAnnotationId}
|
||||||
|
<h4
|
||||||
|
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||||
|
>
|
||||||
|
{m.comment_section_title()}
|
||||||
|
</h4>
|
||||||
|
{/if}
|
||||||
|
<CommentThread
|
||||||
|
documentId={documentId}
|
||||||
|
initialComments={initialComments}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
446
frontend/src/lib/components/PanelHistory.svelte
Normal file
446
frontend/src/lib/components/PanelHistory.svelte
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { diffWords } from 'diff';
|
||||||
|
|
||||||
|
let { documentId }: { documentId: string } = $props();
|
||||||
|
|
||||||
|
type VersionSummary = {
|
||||||
|
id: string;
|
||||||
|
savedAt: string;
|
||||||
|
editorName: string;
|
||||||
|
changedFields: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SnapshotDoc = {
|
||||||
|
title?: string;
|
||||||
|
documentDate?: string;
|
||||||
|
location?: string;
|
||||||
|
documentLocation?: string;
|
||||||
|
transcription?: string;
|
||||||
|
summary?: string;
|
||||||
|
sender?: { id: string; firstName: string; lastName: string } | null;
|
||||||
|
receivers?: { id: string; firstName: string; lastName: string }[];
|
||||||
|
tags?: { id: string; name: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiffEntry =
|
||||||
|
| {
|
||||||
|
kind: 'text';
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
parts: { value: string; added?: boolean; removed?: boolean }[];
|
||||||
|
}
|
||||||
|
| { kind: 'scalar'; field: string; label: string; oldVal: string; newVal: string }
|
||||||
|
| { kind: 'relation'; field: string; label: string; removed: string[]; added: string[] };
|
||||||
|
|
||||||
|
let historyLoaded = $state(false);
|
||||||
|
let historyLoading = $state(false);
|
||||||
|
let versions = $state<VersionSummary[]>([]);
|
||||||
|
|
||||||
|
let compareMode = $state(false);
|
||||||
|
let compareA = $state('');
|
||||||
|
let compareB = $state('');
|
||||||
|
|
||||||
|
let selectedVersionId = $state<string | null>(null);
|
||||||
|
let diffEntries = $state<DiffEntry[]>([]);
|
||||||
|
let diffLoading = $state(false);
|
||||||
|
let noDiff = $state(false);
|
||||||
|
|
||||||
|
const fieldLabels: Record<string, () => string> = {
|
||||||
|
title: m.history_field_title,
|
||||||
|
documentDate: m.history_field_document_date,
|
||||||
|
location: m.history_field_location,
|
||||||
|
documentLocation: m.history_field_document_location,
|
||||||
|
transcription: m.history_field_transcription,
|
||||||
|
summary: m.history_field_summary,
|
||||||
|
sender: m.history_field_sender,
|
||||||
|
receivers: m.history_field_receivers,
|
||||||
|
tags: m.history_field_tags
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEXT_FIELDS = ['title', 'summary', 'transcription'] as const;
|
||||||
|
const SCALAR_FIELDS = ['documentDate', 'location', 'documentLocation'] as const;
|
||||||
|
|
||||||
|
function parseSnapshot(raw: string): SnapshotDoc {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as SnapshotDoc;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function personLabel(p: { firstName: string; lastName: string }): string {
|
||||||
|
return `${p.firstName} ${p.lastName}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFF_CONTEXT_WORDS = 4;
|
||||||
|
|
||||||
|
type DiffPart = { value: string; added?: boolean; removed?: boolean };
|
||||||
|
|
||||||
|
function trimContextParts(parts: DiffPart[]): DiffPart[] {
|
||||||
|
return parts.flatMap((part, i) => {
|
||||||
|
if (part.added || part.removed) return [part];
|
||||||
|
const tokens = part.value.split(/(\s+)/).filter(Boolean);
|
||||||
|
const wordCount = tokens.filter((t) => /\S/.test(t)).length;
|
||||||
|
if (wordCount <= DIFF_CONTEXT_WORDS * 2) return [part];
|
||||||
|
|
||||||
|
function keepFirst(n: number): string {
|
||||||
|
let count = 0;
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const t of tokens) {
|
||||||
|
out.push(t);
|
||||||
|
if (/\S/.test(t) && ++count >= n) break;
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
function keepLast(n: number): string {
|
||||||
|
let count = 0;
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const t of [...tokens].reverse()) {
|
||||||
|
out.unshift(t);
|
||||||
|
if (/\S/.test(t) && ++count >= n) break;
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFirst = i === 0;
|
||||||
|
const isLast = i === parts.length - 1;
|
||||||
|
if (isFirst) return [{ value: '… ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
||||||
|
if (isLast) return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' …' }];
|
||||||
|
return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' … ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
||||||
|
const entries: DiffEntry[] = [];
|
||||||
|
|
||||||
|
for (const field of TEXT_FIELDS) {
|
||||||
|
const a = older?.[field] ?? '';
|
||||||
|
const b = newer[field] ?? '';
|
||||||
|
if (a === b) continue;
|
||||||
|
const parts = trimContextParts(diffWords(a, b));
|
||||||
|
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of SCALAR_FIELDS) {
|
||||||
|
const a = older?.[field] ?? '';
|
||||||
|
const b = newer[field] ?? '';
|
||||||
|
if (a === b) continue;
|
||||||
|
entries.push({ kind: 'scalar', field, label: fieldLabels[field](), oldVal: a, newVal: b });
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderA = older?.sender ? personLabel(older.sender) : '';
|
||||||
|
const senderB = newer.sender ? personLabel(newer.sender) : '';
|
||||||
|
if (senderA !== senderB) {
|
||||||
|
entries.push({
|
||||||
|
kind: 'relation',
|
||||||
|
field: 'sender',
|
||||||
|
label: fieldLabels['sender'](),
|
||||||
|
removed: senderA ? [senderA] : [],
|
||||||
|
added: senderB ? [senderB] : []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const receiversA = new Set((older?.receivers ?? []).map(personLabel));
|
||||||
|
const receiversB = new Set((newer.receivers ?? []).map(personLabel));
|
||||||
|
const removedReceivers = [...receiversA].filter((r) => !receiversB.has(r));
|
||||||
|
const addedReceivers = [...receiversB].filter((r) => !receiversA.has(r));
|
||||||
|
if (removedReceivers.length > 0 || addedReceivers.length > 0) {
|
||||||
|
entries.push({
|
||||||
|
kind: 'relation',
|
||||||
|
field: 'receivers',
|
||||||
|
label: fieldLabels['receivers'](),
|
||||||
|
removed: removedReceivers,
|
||||||
|
added: addedReceivers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsA = new Set((older?.tags ?? []).map((t) => t.name));
|
||||||
|
const tagsB = new Set((newer.tags ?? []).map((t) => t.name));
|
||||||
|
const removedTags = [...tagsA].filter((t) => !tagsB.has(t));
|
||||||
|
const addedTags = [...tagsB].filter((t) => !tagsA.has(t));
|
||||||
|
if (removedTags.length > 0 || addedTags.length > 0) {
|
||||||
|
entries.push({
|
||||||
|
kind: 'relation',
|
||||||
|
field: 'tags',
|
||||||
|
label: fieldLabels['tags'](),
|
||||||
|
removed: removedTags,
|
||||||
|
added: addedTags
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSnapshot(versionId: string): Promise<SnapshotDoc> {
|
||||||
|
const res = await fetch(`/api/documents/${documentId}/versions/${versionId}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch version');
|
||||||
|
const v = await res.json();
|
||||||
|
return parseSnapshot(v.snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
if (historyLoaded) return;
|
||||||
|
historyLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/${documentId}/versions`);
|
||||||
|
if (res.ok) {
|
||||||
|
versions = await res.json();
|
||||||
|
}
|
||||||
|
historyLoaded = true;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
historyLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectVersion(versionId: string) {
|
||||||
|
if (selectedVersionId === versionId) {
|
||||||
|
selectedVersionId = null;
|
||||||
|
diffEntries = [];
|
||||||
|
noDiff = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedVersionId = versionId;
|
||||||
|
diffEntries = [];
|
||||||
|
noDiff = false;
|
||||||
|
diffLoading = true;
|
||||||
|
try {
|
||||||
|
const idx = versions.findIndex((v) => v.id === versionId);
|
||||||
|
const newerSnap = await fetchSnapshot(versionId);
|
||||||
|
const olderSnap = idx > 0 ? await fetchSnapshot(versions[idx - 1].id) : null;
|
||||||
|
const entries = buildDiff(olderSnap, newerSnap);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
noDiff = true;
|
||||||
|
} else {
|
||||||
|
diffEntries = entries;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
diffLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCompare() {
|
||||||
|
if (!compareA || !compareB || compareA === compareB) return;
|
||||||
|
selectedVersionId = null;
|
||||||
|
diffEntries = [];
|
||||||
|
noDiff = false;
|
||||||
|
diffLoading = true;
|
||||||
|
try {
|
||||||
|
const [snapA, snapB] = await Promise.all([fetchSnapshot(compareA), fetchSnapshot(compareB)]);
|
||||||
|
const entries = buildDiff(snapA, snapB);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
noDiff = true;
|
||||||
|
} else {
|
||||||
|
diffEntries = entries;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
diffLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(new Date(iso));
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionLabel(v: VersionSummary, index: number): string {
|
||||||
|
return `Version ${index + 1} — ${v.editorName} — ${formatDateTime(v.savedAt)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load history when this panel mounts.
|
||||||
|
$effect(() => {
|
||||||
|
loadHistory();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4 p-6">
|
||||||
|
{#if historyLoading}
|
||||||
|
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
||||||
|
{:else if !historyLoaded}
|
||||||
|
<!-- initial state before effect runs — show nothing -->
|
||||||
|
{:else if versions.length === 0}
|
||||||
|
<p class="font-serif text-sm text-gray-400 italic">{m.history_empty()}</p>
|
||||||
|
{:else}
|
||||||
|
<!-- Compare mode toggle -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
compareMode = !compareMode;
|
||||||
|
diffEntries = [];
|
||||||
|
noDiff = false;
|
||||||
|
selectedVersionId = null;
|
||||||
|
}}
|
||||||
|
class="font-sans text-xs font-medium transition {compareMode
|
||||||
|
? 'text-brand-navy underline'
|
||||||
|
: 'text-gray-400 hover:text-brand-navy'}"
|
||||||
|
>
|
||||||
|
{m.history_compare_mode()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if compareMode}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label for="compare-a" class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
|
||||||
|
>{m.history_compare_select_a()}</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="compare-a"
|
||||||
|
bind:value={compareA}
|
||||||
|
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{#each versions as v, i (v.id)}
|
||||||
|
<option value={v.id}>{versionLabel(v, i)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="compare-b" class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
|
||||||
|
>{m.history_compare_select_b()}</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="compare-b"
|
||||||
|
bind:value={compareB}
|
||||||
|
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{#each versions as v, i (v.id)}
|
||||||
|
<option value={v.id}>{versionLabel(v, i)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={applyCompare}
|
||||||
|
disabled={!compareA || !compareB || compareA === compareB}
|
||||||
|
class="w-full rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white transition hover:bg-brand-navy/80 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{m.history_compare_apply()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Version list -->
|
||||||
|
<ul class="divide-y divide-brand-sand">
|
||||||
|
{#each versions as v, i (v.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onclick={() => selectVersion(v.id)}
|
||||||
|
data-testid="history-version"
|
||||||
|
class="w-full py-2 text-left transition hover:bg-brand-sand/30 {selectedVersionId ===
|
||||||
|
v.id
|
||||||
|
? 'border-l-2 border-brand-mint pl-2'
|
||||||
|
: 'pl-0'}"
|
||||||
|
>
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<span class="font-sans text-xs font-medium text-brand-navy">
|
||||||
|
Version {i + 1}
|
||||||
|
</span>
|
||||||
|
<span class="font-sans text-[10px] text-gray-400">
|
||||||
|
{formatDateTime(v.savedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-sans text-[11px] text-gray-500">{v.editorName}</span>
|
||||||
|
{#if v.changedFields && v.changedFields.length > 0}
|
||||||
|
<div class="mt-1 flex flex-wrap gap-1">
|
||||||
|
{#each v.changedFields as field (field)}
|
||||||
|
<span
|
||||||
|
class="rounded bg-brand-sand/50 px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-gray-500 uppercase"
|
||||||
|
>
|
||||||
|
{fieldLabels[field] ? fieldLabels[field]() : field}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Diff panel -->
|
||||||
|
{#if diffLoading}
|
||||||
|
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
||||||
|
{:else if noDiff}
|
||||||
|
<div
|
||||||
|
data-testid="history-diff"
|
||||||
|
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
|
||||||
|
>
|
||||||
|
{m.history_diff_no_changes()}
|
||||||
|
</div>
|
||||||
|
{:else if diffEntries.length > 0}
|
||||||
|
<div
|
||||||
|
data-testid="history-diff"
|
||||||
|
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4"
|
||||||
|
>
|
||||||
|
{#each diffEntries as entry (entry.field)}
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
|
||||||
|
>{entry.label}</span
|
||||||
|
>
|
||||||
|
{#if entry.kind === 'text'}
|
||||||
|
<p class="font-serif text-sm leading-relaxed">
|
||||||
|
{#each entry.parts as part, partIdx (partIdx)}
|
||||||
|
{#if part.added}
|
||||||
|
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||||
|
{:else if part.removed}
|
||||||
|
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
|
||||||
|
{:else}
|
||||||
|
<span>{part.value}</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
{:else if entry.kind === 'scalar'}
|
||||||
|
<div class="flex items-center gap-2 font-serif text-sm">
|
||||||
|
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||||
|
<svg
|
||||||
|
class="h-3 w-3 flex-shrink-0 text-gray-400"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||||
|
</div>
|
||||||
|
{:else if entry.kind === 'relation'}
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each entry.removed as item (item)}
|
||||||
|
<span
|
||||||
|
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||||
|
>{item}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{#each entry.added as item (item)}
|
||||||
|
<span
|
||||||
|
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||||
|
>{item}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
202
frontend/src/lib/components/PanelMetadata.svelte
Normal file
202
frontend/src/lib/components/PanelMetadata.svelte
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { formatDate } from '$lib/utils/date';
|
||||||
|
|
||||||
|
type Person = { id: string; firstName: string; lastName: string; alias?: string | null };
|
||||||
|
type Tag = { id: string; name: string };
|
||||||
|
|
||||||
|
type Doc = {
|
||||||
|
documentDate?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
documentLocation?: string | null;
|
||||||
|
tags?: Tag[] | null;
|
||||||
|
sender?: Person | null;
|
||||||
|
receivers?: Person[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { doc }: { doc: Doc } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-10 p-6">
|
||||||
|
<!-- DETAILS GROUP -->
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||||
|
>
|
||||||
|
{m.doc_section_details()}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<!-- Date -->
|
||||||
|
<div class="flex items-start">
|
||||||
|
<span class="mt-0.5 w-8 text-brand-mint">
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<span class="block font-serif text-lg text-brand-navy">
|
||||||
|
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||||
|
</span>
|
||||||
|
<span class="font-sans text-xs text-gray-500">{m.doc_label_document_date()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Creation Location -->
|
||||||
|
<div class="flex items-start">
|
||||||
|
<span class="mt-0.5 w-8 text-brand-mint">
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<span class="block font-serif text-lg text-brand-navy">
|
||||||
|
{doc.location ? doc.location : '—'}
|
||||||
|
</span>
|
||||||
|
<span class="font-sans text-xs text-gray-500">{m.doc_label_creation_location()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Physical Archive Location -->
|
||||||
|
{#if doc.documentLocation}
|
||||||
|
<div class="flex items-start">
|
||||||
|
<span class="mt-0.5 w-8 text-brand-mint">
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<span class="block font-serif text-lg text-brand-navy">
|
||||||
|
{doc.documentLocation}
|
||||||
|
</span>
|
||||||
|
<span class="font-sans text-xs text-gray-500"
|
||||||
|
>{m.doc_label_archive_location_original()}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
{#if doc.tags && doc.tags.length > 0}
|
||||||
|
<div class="flex items-start">
|
||||||
|
<span class="mt-0.5 w-8 text-brand-mint">
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="mb-1 flex flex-wrap gap-2">
|
||||||
|
{#each doc.tags as tag (tag.id)}
|
||||||
|
<a
|
||||||
|
href="/?tag={encodeURIComponent(tag.name)}"
|
||||||
|
class="inline-flex items-center rounded bg-brand-sand/50 px-2 py-0.5 text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
||||||
|
title={m.doc_tag_filter_title({ name: tag.name })}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<span class="font-sans text-xs text-gray-500">{m.form_label_tags()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PERSONEN GROUP -->
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||||
|
>
|
||||||
|
{m.doc_section_persons()}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||||
|
>{m.form_label_sender()}</span
|
||||||
|
>
|
||||||
|
{#if doc.sender}
|
||||||
|
<a
|
||||||
|
href="/persons/{doc.sender.id}"
|
||||||
|
class="group block rounded border border-brand-sand bg-brand-sand/20 p-3 transition hover:border-brand-mint hover:bg-brand-mint/10"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-serif text-sm text-white"
|
||||||
|
>
|
||||||
|
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
class="font-serif text-brand-navy decoration-brand-mint underline-offset-2 group-hover:underline"
|
||||||
|
>
|
||||||
|
{doc.sender.firstName}
|
||||||
|
{doc.sender.lastName}
|
||||||
|
</p>
|
||||||
|
{#if doc.sender.alias}
|
||||||
|
<p class="font-sans text-xs text-gray-500">{doc.sender.alias}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="font-serif text-sm text-gray-400 italic">{m.doc_sender_not_specified()}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||||
|
>{m.form_label_receivers()}</span
|
||||||
|
>
|
||||||
|
{#if doc.receivers && doc.receivers.length > 0}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each doc.receivers as receiver (receiver.id)}
|
||||||
|
<div
|
||||||
|
class="group flex items-center justify-between rounded border border-brand-sand bg-white p-3 transition hover:border-brand-navy"
|
||||||
|
>
|
||||||
|
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-6 w-6 items-center justify-center rounded-full bg-gray-100 font-serif text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
{receiver.firstName[0]}{receiver.lastName[0]}
|
||||||
|
</div>
|
||||||
|
<span class="truncate font-serif text-sm text-brand-navy">
|
||||||
|
{receiver.firstName}
|
||||||
|
{receiver.lastName}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{#if doc.sender}
|
||||||
|
<a
|
||||||
|
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
|
||||||
|
class="text-gray-300 transition hover:text-brand-mint"
|
||||||
|
title={m.doc_conversation_title()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="font-serif text-sm text-gray-400 italic">{m.doc_no_receivers()}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
42
frontend/src/lib/components/PanelTranscription.svelte
Normal file
42
frontend/src/lib/components/PanelTranscription.svelte
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type Doc = {
|
||||||
|
summary?: string | null;
|
||||||
|
transcription?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { doc }: { doc: Doc } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex justify-center px-6 py-8">
|
||||||
|
<div class="w-full max-w-prose space-y-8">
|
||||||
|
{#if !doc.summary && !doc.transcription}
|
||||||
|
<p class="font-serif text-sm text-gray-400 italic">—</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if doc.summary}
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mb-3 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||||
|
>
|
||||||
|
{m.doc_label_summary()}
|
||||||
|
</span>
|
||||||
|
<p class="font-serif text-base leading-relaxed text-brand-navy">{doc.summary}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if doc.transcription}
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mb-3 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||||
|
>
|
||||||
|
{m.form_label_transcription()}
|
||||||
|
</span>
|
||||||
|
<p class="font-serif text-base leading-relaxed whitespace-pre-wrap text-brand-navy">
|
||||||
|
{doc.transcription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -3,25 +3,19 @@ import { onMount } from 'svelte';
|
|||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||||
import AnnotationCommentPanel from './AnnotationCommentPanel.svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
url,
|
url,
|
||||||
documentId = '',
|
documentId = '',
|
||||||
canAnnotate = false,
|
annotateMode = $bindable(false),
|
||||||
canComment,
|
activeAnnotationId = $bindable<string | null>(null),
|
||||||
currentUserId,
|
onAnnotationClick
|
||||||
canAdmin,
|
|
||||||
documentFileHash
|
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
canAnnotate?: boolean;
|
annotateMode?: boolean;
|
||||||
canComment?: boolean;
|
activeAnnotationId?: string | null;
|
||||||
currentUserId?: string | null;
|
onAnnotationClick?: (id: string) => void;
|
||||||
canAdmin?: boolean;
|
|
||||||
documentFileHash?: string | null;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||||
@@ -54,19 +48,11 @@ type Annotation = {
|
|||||||
height: number;
|
height: number;
|
||||||
color: string;
|
color: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
fileHash?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let annotations = $state<Annotation[]>([]);
|
let annotations = $state<Annotation[]>([]);
|
||||||
let annotateMode = $state(false);
|
|
||||||
let annotateColor = $state('#ffff00');
|
let annotateColor = $state('#ffff00');
|
||||||
let commentCounts = new SvelteMap<string, number>();
|
let commentCounts = new SvelteMap<string, number>();
|
||||||
let activeAnnotationId = $state<string | null>(null);
|
|
||||||
|
|
||||||
const visibleAnnotations = $derived(
|
|
||||||
annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash)
|
|
||||||
);
|
|
||||||
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
||||||
@@ -227,6 +213,7 @@ async function handleAnnotationDraw(rect: { x: number; y: number; width: number;
|
|||||||
const created: Annotation = await res.json();
|
const created: Annotation = await res.json();
|
||||||
annotations = [...annotations, created];
|
annotations = [...annotations, created];
|
||||||
activeAnnotationId = created.id;
|
activeAnnotationId = created.id;
|
||||||
|
onAnnotationClick?.(created.id);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -247,6 +234,11 @@ async function handleAnnotationDelete(annotationId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAnnotationClick(id: string) {
|
||||||
|
activeAnnotationId = id;
|
||||||
|
onAnnotationClick?.(id);
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (pdfjsReady && url) {
|
if (pdfjsReady && url) {
|
||||||
loadDocument(url);
|
loadDocument(url);
|
||||||
@@ -307,27 +299,6 @@ function zoomOut() {
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex h-full w-full flex-col bg-[#2A2A2A]">
|
<div class="flex h-full w-full flex-col bg-[#2A2A2A]">
|
||||||
{#if outdatedCount > 0}
|
|
||||||
<div
|
|
||||||
class="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2"
|
|
||||||
data-testid="annotation-outdated-notice"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4 shrink-0 text-amber-400"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="font-sans text-xs text-amber-300">{m.annotation_outdated_notice()}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<!-- Controls -->
|
<!-- Controls -->
|
||||||
<div
|
<div
|
||||||
class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2"
|
class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2"
|
||||||
@@ -415,35 +386,15 @@ function zoomOut() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Annotate controls -->
|
<!-- Color picker (shown in annotate mode) -->
|
||||||
{#if canAnnotate}
|
{#if annotateMode}
|
||||||
<div class="flex items-center gap-1">
|
<input
|
||||||
<button
|
type="color"
|
||||||
onclick={() => (annotateMode = !annotateMode)}
|
bind:value={annotateColor}
|
||||||
aria-label={annotateMode ? 'Annotieren beenden' : 'Annotieren'}
|
aria-label="Farbe wählen"
|
||||||
class="rounded px-2 py-1 font-sans text-xs text-gray-300 transition hover:bg-white/10 {annotateMode ? 'bg-white/20' : ''}"
|
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent p-0"
|
||||||
>
|
title="Farbe wählen"
|
||||||
{annotateMode ? 'Fertig' : 'Annotieren'}
|
/>
|
||||||
</button>
|
|
||||||
{#if annotateMode}
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
bind:value={annotateColor}
|
|
||||||
aria-label="Farbe wählen"
|
|
||||||
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent p-0"
|
|
||||||
title="Farbe wählen"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
title="Sie benötigen die Berechtigung ANNOTATE_ALL zum Annotieren"
|
|
||||||
class="cursor-not-allowed rounded px-2 py-1 font-sans text-xs text-gray-500"
|
|
||||||
aria-label="Annotieren (keine Berechtigung)"
|
|
||||||
>
|
|
||||||
Annotieren
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -469,33 +420,17 @@ function zoomOut() {
|
|||||||
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
|
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
|
||||||
></div>
|
></div>
|
||||||
<AnnotationLayer
|
<AnnotationLayer
|
||||||
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
|
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
|
||||||
canAnnotate={annotateMode}
|
canAnnotate={annotateMode}
|
||||||
color={annotateColor}
|
color={annotateColor}
|
||||||
onDraw={handleAnnotationDraw}
|
onDraw={handleAnnotationDraw}
|
||||||
onDelete={handleAnnotationDelete}
|
onDelete={handleAnnotationDelete}
|
||||||
commentCounts={Object.fromEntries(commentCounts)}
|
commentCounts={Object.fromEntries(commentCounts)}
|
||||||
onAnnotationClick={(id) => (activeAnnotationId = id)}
|
onAnnotationClick={handleAnnotationClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#key activeAnnotationId}
|
|
||||||
{#if activeAnnotationId}
|
|
||||||
<AnnotationCommentPanel
|
|
||||||
documentId={documentId}
|
|
||||||
annotationId={activeAnnotationId}
|
|
||||||
canComment={canComment ?? false}
|
|
||||||
currentUserId={currentUserId ?? null}
|
|
||||||
canAdmin={canAdmin ?? false}
|
|
||||||
onClose={() => (activeAnnotationId = null)}
|
|
||||||
onCountChange={(count) => {
|
|
||||||
if (activeAnnotationId) commentCounts.set(activeAnnotationId, count);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/key}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -180,86 +180,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/documents/{documentId}/comments": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["getDocumentComments"];
|
|
||||||
put?: never;
|
|
||||||
post: operations["postDocumentComment"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/documents/{documentId}/comments/{commentId}/replies": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post: operations["replyToDocumentComment"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/documents/{documentId}/annotations": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["listAnnotations"];
|
|
||||||
put?: never;
|
|
||||||
post: operations["createAnnotation"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/documents/{documentId}/annotations/{annotationId}/comments": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["getAnnotationComments"];
|
|
||||||
put?: never;
|
|
||||||
post: operations["postAnnotationComment"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post: operations["replyToAnnotationComment"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/auth/reset-password": {
|
"/api/auth/reset-password": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -340,22 +260,6 @@ export interface paths {
|
|||||||
patch: operations["updateGroup"];
|
patch: operations["updateGroup"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/documents/{documentId}/comments/{commentId}": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete: operations["deleteComment"];
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch: operations["editComment"];
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/tags": {
|
"/api/tags": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -516,22 +420,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/documents/{documentId}/annotations/{annotationId}": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete: operations["deleteAnnotation"];
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -622,7 +510,6 @@ export interface components {
|
|||||||
title: string;
|
title: string;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
fileHash?: string;
|
|
||||||
originalFilename: string;
|
originalFilename: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||||
@@ -661,63 +548,6 @@ export interface components {
|
|||||||
name?: string;
|
name?: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
};
|
};
|
||||||
CreateCommentDTO: {
|
|
||||||
content?: string;
|
|
||||||
};
|
|
||||||
DocumentComment: {
|
|
||||||
/** Format: uuid */
|
|
||||||
id: string;
|
|
||||||
/** Format: uuid */
|
|
||||||
documentId: string;
|
|
||||||
/** Format: uuid */
|
|
||||||
annotationId?: string;
|
|
||||||
/** Format: uuid */
|
|
||||||
parentId?: string;
|
|
||||||
/** Format: uuid */
|
|
||||||
authorId?: string;
|
|
||||||
authorName: string;
|
|
||||||
content: string;
|
|
||||||
/** Format: date-time */
|
|
||||||
createdAt: string;
|
|
||||||
/** Format: date-time */
|
|
||||||
updatedAt: string;
|
|
||||||
replies: components["schemas"]["DocumentComment"][];
|
|
||||||
};
|
|
||||||
CreateAnnotationDTO: {
|
|
||||||
/** Format: int32 */
|
|
||||||
pageNumber?: number;
|
|
||||||
/** Format: double */
|
|
||||||
x?: number;
|
|
||||||
/** Format: double */
|
|
||||||
y?: number;
|
|
||||||
/** Format: double */
|
|
||||||
width?: number;
|
|
||||||
/** Format: double */
|
|
||||||
height?: number;
|
|
||||||
color?: string;
|
|
||||||
};
|
|
||||||
DocumentAnnotation: {
|
|
||||||
/** Format: uuid */
|
|
||||||
id: string;
|
|
||||||
/** Format: uuid */
|
|
||||||
documentId: string;
|
|
||||||
/** Format: int32 */
|
|
||||||
pageNumber: number;
|
|
||||||
/** Format: double */
|
|
||||||
x: number;
|
|
||||||
/** Format: double */
|
|
||||||
y: number;
|
|
||||||
/** Format: double */
|
|
||||||
width: number;
|
|
||||||
/** Format: double */
|
|
||||||
height: number;
|
|
||||||
color: string;
|
|
||||||
fileHash?: string;
|
|
||||||
/** Format: uuid */
|
|
||||||
createdBy?: string;
|
|
||||||
/** Format: date-time */
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
ResetPasswordRequest: {
|
ResetPasswordRequest: {
|
||||||
token?: string;
|
token?: string;
|
||||||
newPassword?: string;
|
newPassword?: string;
|
||||||
@@ -1232,205 +1062,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
getDocumentComments: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
documentId: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"*/*": components["schemas"]["DocumentComment"][];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
postDocumentComment: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
documentId: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["CreateCommentDTO"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Created */
|
|
||||||
201: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"*/*": components["schemas"]["DocumentComment"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
replyToDocumentComment: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
documentId: string;
|
|
||||||
commentId: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["CreateCommentDTO"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Created */
|
|
||||||
201: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"*/*": components["schemas"]["DocumentComment"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
listAnnotations: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
documentId: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"*/*": components["schemas"]["DocumentAnnotation"][];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
createAnnotation: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
documentId: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["CreateAnnotationDTO"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Created */
|
|
||||||
201: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"*/*": components["schemas"]["DocumentAnnotation"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
getAnnotationComments: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
annotationId: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"*/*": components["schemas"]["DocumentComment"][];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
postAnnotationComment: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
documentId: string;
|
|
||||||
annotationId: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["CreateCommentDTO"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Created */
|
|
||||||
201: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"*/*": components["schemas"]["DocumentComment"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
replyToAnnotationComment: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
documentId: string;
|
|
||||||
commentId: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["CreateCommentDTO"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Created */
|
|
||||||
201: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"*/*": components["schemas"]["DocumentComment"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
resetPassword: {
|
resetPassword: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1561,54 +1192,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
deleteComment: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
documentId: string;
|
|
||||||
commentId: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description No Content */
|
|
||||||
204: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
editComment: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
documentId: string;
|
|
||||||
commentId: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["CreateCommentDTO"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"*/*": components["schemas"]["DocumentComment"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
searchTags: {
|
searchTags: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -1839,25 +1422,4 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
deleteAnnotation: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
documentId: string;
|
|
||||||
annotationId: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description No Content */
|
|
||||||
204: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,16 @@ $effect(() => {
|
|||||||
>
|
>
|
||||||
{doc.title || doc.originalFilename}
|
{doc.title || doc.originalFilename}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
<!-- Status Badge -->
|
||||||
|
<span
|
||||||
|
class="ml-3 inline-flex items-center rounded-full border px-2.5 py-0.5 text-[10px] font-bold tracking-wide uppercase
|
||||||
|
{doc.status === 'UPLOADED'
|
||||||
|
? 'border-brand-mint/50 bg-brand-mint/20 text-brand-navy'
|
||||||
|
: 'border-yellow-200 bg-yellow-50 text-yellow-700'}"
|
||||||
|
>
|
||||||
|
{doc.status}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Metadata Row -->
|
<!-- Metadata Row -->
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ let editingTagName = $state('');
|
|||||||
let editingGroupId: string | null = $state(null);
|
let editingGroupId: string | null = $state(null);
|
||||||
let backfillResult: number | null = $state(null);
|
let backfillResult: number | null = $state(null);
|
||||||
let backfillLoading = $state(false);
|
let backfillLoading = $state(false);
|
||||||
let backfillHashesResult: number | null = $state(null);
|
|
||||||
let backfillHashesLoading = $state(false);
|
|
||||||
|
|
||||||
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
||||||
|
|
||||||
@@ -47,20 +45,6 @@ async function backfillVersions() {
|
|||||||
backfillLoading = false;
|
backfillLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function backfillFileHashes() {
|
|
||||||
backfillHashesLoading = true;
|
|
||||||
backfillHashesResult = null;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/backfill-file-hashes', { method: 'POST' });
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
backfillHashesResult = data.count;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
backfillHashesLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
||||||
@@ -551,24 +535,5 @@ async function backfillFileHashes() {
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
|
||||||
<h2 class="mb-1 text-lg font-bold text-gray-700">
|
|
||||||
{m.admin_system_backfill_hashes_heading()}
|
|
||||||
</h2>
|
|
||||||
<p class="mb-4 text-sm text-gray-500">{m.admin_system_backfill_hashes_description()}</p>
|
|
||||||
<button
|
|
||||||
onclick={backfillFileHashes}
|
|
||||||
disabled={backfillHashesLoading}
|
|
||||||
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-brand-mint hover:text-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{backfillHashesLoading ? '…' : m.admin_system_backfill_hashes_btn()}
|
|
||||||
</button>
|
|
||||||
{#if backfillHashesResult !== null}
|
|
||||||
<p class="mt-4 text-sm font-medium text-brand-navy">
|
|
||||||
{m.admin_system_backfill_hashes_success({ count: backfillHashesResult })}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { onMount } from 'svelte';
|
||||||
import { formatDate } from '$lib/utils/date';
|
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||||
import { diffWords } from 'diff';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
import ExpandableText from '$lib/components/ExpandableText.svelte';
|
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
||||||
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
|
||||||
import CommentThread from '$lib/components/CommentThread.svelte';
|
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -17,9 +17,11 @@ const canAdmin = $derived(
|
|||||||
);
|
);
|
||||||
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
||||||
|
|
||||||
|
// ── File loading ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let fileUrl = $state('');
|
let fileUrl = $state('');
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let error = $state('');
|
let fileError = $state('');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (doc?.id && doc?.filePath) {
|
if (doc?.id && doc?.filePath) {
|
||||||
@@ -29,7 +31,7 @@ $effect(() => {
|
|||||||
|
|
||||||
async function loadFile(id: string) {
|
async function loadFile(id: string) {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
error = '';
|
fileError = '';
|
||||||
fileUrl = '';
|
fileUrl = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -44,873 +46,104 @@ async function loadFile(id: string) {
|
|||||||
fileUrl = URL.createObjectURL(blob);
|
fileUrl = URL.createObjectURL(blob);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
error = m.doc_file_error_preview();
|
fileError = 'Vorschau konnte nicht geladen werden.';
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── History panel ────────────────────────────────────────────────────────────
|
// ── Annotation state (lifted from PdfViewer) ──────────────────────────────────
|
||||||
|
|
||||||
type VersionSummary = {
|
let annotateMode = $state(false);
|
||||||
id: string;
|
let activeAnnotationId = $state<string | null>(null);
|
||||||
savedAt: string;
|
|
||||||
editorName: string;
|
|
||||||
changedFields: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type SnapshotDoc = {
|
// When an annotation is clicked, open the Diskussion tab.
|
||||||
title?: string;
|
$effect(() => {
|
||||||
documentDate?: string;
|
if (activeAnnotationId) {
|
||||||
location?: string;
|
activeTab = 'discussion';
|
||||||
documentLocation?: string;
|
panelOpen = true;
|
||||||
transcription?: string;
|
|
||||||
summary?: string;
|
|
||||||
sender?: { id: string; firstName: string; lastName: string } | null;
|
|
||||||
receivers?: { id: string; firstName: string; lastName: string }[];
|
|
||||||
tags?: { id: string; name: string }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type DiffEntry =
|
|
||||||
| {
|
|
||||||
kind: 'text';
|
|
||||||
field: string;
|
|
||||||
label: string;
|
|
||||||
parts: { value: string; added?: boolean; removed?: boolean }[];
|
|
||||||
}
|
|
||||||
| { kind: 'scalar'; field: string; label: string; oldVal: string; newVal: string }
|
|
||||||
| { kind: 'relation'; field: string; label: string; removed: string[]; added: string[] };
|
|
||||||
|
|
||||||
let historyOpen = $state(false);
|
|
||||||
let historyLoaded = $state(false);
|
|
||||||
let historyLoading = $state(false);
|
|
||||||
let versions = $state<VersionSummary[]>([]);
|
|
||||||
|
|
||||||
let compareMode = $state(false);
|
|
||||||
let compareA = $state('');
|
|
||||||
let compareB = $state('');
|
|
||||||
|
|
||||||
let selectedVersionId = $state<string | null>(null);
|
|
||||||
let diffEntries = $state<DiffEntry[]>([]);
|
|
||||||
let diffLoading = $state(false);
|
|
||||||
let noDiff = $state(false);
|
|
||||||
|
|
||||||
const fieldLabels: Record<string, () => string> = {
|
|
||||||
title: m.history_field_title,
|
|
||||||
documentDate: m.history_field_document_date,
|
|
||||||
location: m.history_field_location,
|
|
||||||
documentLocation: m.history_field_document_location,
|
|
||||||
transcription: m.history_field_transcription,
|
|
||||||
summary: m.history_field_summary,
|
|
||||||
sender: m.history_field_sender,
|
|
||||||
receivers: m.history_field_receivers,
|
|
||||||
tags: m.history_field_tags
|
|
||||||
};
|
|
||||||
|
|
||||||
const TEXT_FIELDS = ['title', 'summary', 'transcription'] as const;
|
|
||||||
const SCALAR_FIELDS = ['documentDate', 'location', 'documentLocation'] as const;
|
|
||||||
|
|
||||||
function parseSnapshot(raw: string): SnapshotDoc {
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as SnapshotDoc;
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
function personLabel(p: { firstName: string; lastName: string }): string {
|
// ── Bottom panel state ────────────────────────────────────────────────────────
|
||||||
return `${p.firstName} ${p.lastName}`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const DIFF_CONTEXT_WORDS = 4;
|
const LS_KEY_OPEN = 'doc-panel-open';
|
||||||
|
const LS_KEY_HEIGHT = 'doc-panel-height';
|
||||||
|
const LS_KEY_TAB = 'doc-panel-tab';
|
||||||
|
|
||||||
type DiffPart = { value: string; added?: boolean; removed?: boolean };
|
let panelOpen = $state(false);
|
||||||
|
let panelHeight = $state(320);
|
||||||
|
let activeTab = $state<Tab>('metadata');
|
||||||
|
let localStorageRestored = $state(false);
|
||||||
|
|
||||||
function trimContextParts(parts: DiffPart[]): DiffPart[] {
|
onMount(() => {
|
||||||
return parts.flatMap((part, i) => {
|
const savedOpen = localStorage.getItem(LS_KEY_OPEN);
|
||||||
if (part.added || part.removed) return [part];
|
const savedHeight = localStorage.getItem(LS_KEY_HEIGHT);
|
||||||
const tokens = part.value.split(/(\s+)/).filter(Boolean);
|
const savedTab = localStorage.getItem(LS_KEY_TAB);
|
||||||
const wordCount = tokens.filter((t) => /\S/.test(t)).length;
|
|
||||||
if (wordCount <= DIFF_CONTEXT_WORDS * 2) return [part];
|
|
||||||
|
|
||||||
function keepFirst(n: number): string {
|
if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) {
|
||||||
let count = 0;
|
activeTab = savedTab as Tab;
|
||||||
const out: string[] = [];
|
}
|
||||||
for (const t of tokens) {
|
if (savedHeight) {
|
||||||
out.push(t);
|
const h = parseInt(savedHeight, 10);
|
||||||
if (/\S/.test(t) && ++count >= n) break;
|
if (!isNaN(h) && h >= 80) panelHeight = h;
|
||||||
}
|
}
|
||||||
return out.join('');
|
if (savedOpen !== null) {
|
||||||
}
|
panelOpen = savedOpen === 'true';
|
||||||
function keepLast(n: number): string {
|
} else if (!doc.filePath) {
|
||||||
let count = 0;
|
// No previous state and no file → open to Metadaten by default
|
||||||
const out: string[] = [];
|
panelOpen = true;
|
||||||
for (const t of [...tokens].reverse()) {
|
activeTab = 'metadata';
|
||||||
out.unshift(t);
|
|
||||||
if (/\S/.test(t) && ++count >= n) break;
|
|
||||||
}
|
|
||||||
return out.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFirst = i === 0;
|
|
||||||
const isLast = i === parts.length - 1;
|
|
||||||
if (isFirst) return [{ value: '… ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
|
||||||
if (isLast) return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' …' }];
|
|
||||||
return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' … ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
|
||||||
const entries: DiffEntry[] = [];
|
|
||||||
|
|
||||||
for (const field of TEXT_FIELDS) {
|
|
||||||
const a = older?.[field] ?? '';
|
|
||||||
const b = newer[field] ?? '';
|
|
||||||
if (a === b) continue;
|
|
||||||
const parts = trimContextParts(diffWords(a, b));
|
|
||||||
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const field of SCALAR_FIELDS) {
|
localStorageRestored = true;
|
||||||
const a = older?.[field] ?? '';
|
});
|
||||||
const b = newer[field] ?? '';
|
|
||||||
if (a === b) continue;
|
|
||||||
entries.push({ kind: 'scalar', field, label: fieldLabels[field](), oldVal: a, newVal: b });
|
|
||||||
}
|
|
||||||
|
|
||||||
// sender
|
// Persist panel state whenever it changes (after initial restore).
|
||||||
const senderA = older?.sender ? personLabel(older.sender) : '';
|
$effect(() => {
|
||||||
const senderB = newer.sender ? personLabel(newer.sender) : '';
|
if (!localStorageRestored) return;
|
||||||
if (senderA !== senderB) {
|
localStorage.setItem(LS_KEY_OPEN, String(panelOpen));
|
||||||
const removed = senderA ? [senderA] : [];
|
localStorage.setItem(LS_KEY_HEIGHT, String(panelHeight));
|
||||||
const added = senderB ? [senderB] : [];
|
localStorage.setItem(LS_KEY_TAB, activeTab);
|
||||||
entries.push({
|
});
|
||||||
kind: 'relation',
|
|
||||||
field: 'sender',
|
|
||||||
label: fieldLabels['sender'](),
|
|
||||||
removed,
|
|
||||||
added
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// receivers
|
|
||||||
const receiversA = new Set((older?.receivers ?? []).map(personLabel));
|
|
||||||
const receiversB = new Set((newer.receivers ?? []).map(personLabel));
|
|
||||||
const removedReceivers = [...receiversA].filter((r) => !receiversB.has(r));
|
|
||||||
const addedReceivers = [...receiversB].filter((r) => !receiversA.has(r));
|
|
||||||
if (removedReceivers.length > 0 || addedReceivers.length > 0) {
|
|
||||||
entries.push({
|
|
||||||
kind: 'relation',
|
|
||||||
field: 'receivers',
|
|
||||||
label: fieldLabels['receivers'](),
|
|
||||||
removed: removedReceivers,
|
|
||||||
added: addedReceivers
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// tags
|
|
||||||
const tagsA = new Set((older?.tags ?? []).map((t) => t.name));
|
|
||||||
const tagsB = new Set((newer.tags ?? []).map((t) => t.name));
|
|
||||||
const removedTags = [...tagsA].filter((t) => !tagsB.has(t));
|
|
||||||
const addedTags = [...tagsB].filter((t) => !tagsA.has(t));
|
|
||||||
if (removedTags.length > 0 || addedTags.length > 0) {
|
|
||||||
entries.push({
|
|
||||||
kind: 'relation',
|
|
||||||
field: 'tags',
|
|
||||||
label: fieldLabels['tags'](),
|
|
||||||
removed: removedTags,
|
|
||||||
added: addedTags
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchSnapshot(versionId: string): Promise<SnapshotDoc> {
|
|
||||||
const res = await fetch(`/api/documents/${doc.id}/versions/${versionId}`);
|
|
||||||
if (!res.ok) throw new Error('Failed to fetch version');
|
|
||||||
const v = await res.json();
|
|
||||||
return parseSnapshot(v.snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleHistory() {
|
|
||||||
historyOpen = !historyOpen;
|
|
||||||
if (historyOpen && !historyLoaded) {
|
|
||||||
historyLoading = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/documents/${doc.id}/versions`);
|
|
||||||
if (res.ok) {
|
|
||||||
versions = await res.json();
|
|
||||||
}
|
|
||||||
historyLoaded = true;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
historyLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectVersion(versionId: string) {
|
|
||||||
if (selectedVersionId === versionId) {
|
|
||||||
selectedVersionId = null;
|
|
||||||
diffEntries = [];
|
|
||||||
noDiff = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectedVersionId = versionId;
|
|
||||||
diffEntries = [];
|
|
||||||
noDiff = false;
|
|
||||||
diffLoading = true;
|
|
||||||
try {
|
|
||||||
const idx = versions.findIndex((v) => v.id === versionId);
|
|
||||||
const newerSnap = await fetchSnapshot(versionId);
|
|
||||||
const olderSnap = idx > 0 ? await fetchSnapshot(versions[idx - 1].id) : null;
|
|
||||||
const entries = buildDiff(olderSnap, newerSnap);
|
|
||||||
if (entries.length === 0) {
|
|
||||||
noDiff = true;
|
|
||||||
} else {
|
|
||||||
diffEntries = entries;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
diffLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyCompare() {
|
|
||||||
if (!compareA || !compareB || compareA === compareB) return;
|
|
||||||
selectedVersionId = null;
|
|
||||||
diffEntries = [];
|
|
||||||
noDiff = false;
|
|
||||||
diffLoading = true;
|
|
||||||
try {
|
|
||||||
const [snapA, snapB] = await Promise.all([fetchSnapshot(compareA), fetchSnapshot(compareB)]);
|
|
||||||
// compareA is the "older" baseline, compareB is "newer"
|
|
||||||
const entries = buildDiff(snapA, snapB);
|
|
||||||
if (entries.length === 0) {
|
|
||||||
noDiff = true;
|
|
||||||
} else {
|
|
||||||
diffEntries = entries;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
diffLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(iso: string): string {
|
|
||||||
try {
|
|
||||||
return new Intl.DateTimeFormat('de-DE', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
}).format(new Date(iso));
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function versionLabel(v: VersionSummary, index: number): string {
|
|
||||||
return `Version ${index + 1} — ${v.editorName} — ${formatDateTime(v.savedAt)}`;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-screen flex-col bg-white">
|
<svelte:head>
|
||||||
<!-- Top Bar -->
|
<title>{doc.title || doc.originalFilename || 'Dokument'}</title>
|
||||||
<div
|
</svelte:head>
|
||||||
class="z-10 flex items-center justify-between border-b border-brand-sand bg-white px-6 py-4 shadow-sm"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-6 overflow-hidden">
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="group flex flex-shrink-0 items-center gap-2 font-sans text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-sand transition-colors group-hover:bg-brand-mint"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span>{m.btn_back()}</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4 overflow-hidden border-l border-gray-200 pl-6">
|
<div class="flex h-screen flex-col overflow-hidden bg-white" data-hydrated>
|
||||||
<h1 class="truncate font-serif text-xl text-brand-navy" title={doc.title}>
|
<DocumentTopBar
|
||||||
{doc.title || doc.originalFilename}
|
doc={doc}
|
||||||
</h1>
|
canWrite={data.canWrite ?? false}
|
||||||
</div>
|
canAnnotate={data.canAnnotate ?? false}
|
||||||
</div>
|
fileUrl={fileUrl}
|
||||||
|
bind:annotateMode={annotateMode}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="ml-4 flex flex-shrink-0 items-center gap-3 font-sans">
|
<div class="relative flex-1 overflow-hidden">
|
||||||
{#if data.canWrite}
|
<DocumentViewer
|
||||||
<a
|
doc={doc}
|
||||||
href="/documents/{doc.id}/edit"
|
fileUrl={fileUrl}
|
||||||
class="flex items-center gap-2 rounded border border-brand-navy bg-transparent px-4 py-2 text-sm font-medium text-brand-navy transition hover:bg-brand-navy hover:text-white"
|
isLoading={isLoading}
|
||||||
>
|
error={fileError}
|
||||||
<img
|
bind:annotateMode={annotateMode}
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
alt=""
|
onAnnotationClick={(id) => {
|
||||||
aria-hidden="true"
|
activeAnnotationId = id;
|
||||||
class="h-4 w-4"
|
}}
|
||||||
/>
|
/>
|
||||||
{m.btn_edit()}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if doc.filePath}
|
|
||||||
<a
|
|
||||||
href={fileUrl}
|
|
||||||
download={doc.originalFilename}
|
|
||||||
class="rounded border border-transparent bg-brand-sand/50 p-2 text-brand-navy transition hover:bg-brand-mint"
|
|
||||||
title={m.doc_download_title()}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-5 w-5"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Area -->
|
|
||||||
<div class="flex flex-1 overflow-hidden">
|
|
||||||
<!-- LEFT SIDEBAR: METADATA -->
|
|
||||||
<aside
|
|
||||||
class="custom-scrollbar w-96 flex-shrink-0 overflow-y-auto border-r border-brand-sand bg-white p-8"
|
|
||||||
>
|
|
||||||
<div class="space-y-10">
|
|
||||||
<!-- 1. DETAILS GROUP -->
|
|
||||||
<div>
|
|
||||||
<h3
|
|
||||||
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
|
||||||
>
|
|
||||||
{m.doc_section_details()}
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-5">
|
|
||||||
<!-- Date -->
|
|
||||||
<div class="group flex items-start">
|
|
||||||
<span class="mt-0.5 w-8 text-brand-mint">
|
|
||||||
<img
|
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-5 w-5"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<span class="block font-serif text-lg text-brand-navy">
|
|
||||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
|
||||||
</span>
|
|
||||||
<span class="font-sans text-xs text-gray-500">{m.doc_label_document_date()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Creation Location -->
|
|
||||||
<div class="group flex items-start">
|
|
||||||
<span class="mt-0.5 w-8 text-brand-mint">
|
|
||||||
<img
|
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-5 w-5"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<span class="block font-serif text-lg text-brand-navy">
|
|
||||||
{doc.location ? doc.location : '—'}
|
|
||||||
</span>
|
|
||||||
<span class="font-sans text-xs text-gray-500"
|
|
||||||
>{m.doc_label_creation_location()}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Physical Archive Location -->
|
|
||||||
{#if doc.documentLocation}
|
|
||||||
<div class="group flex items-start">
|
|
||||||
<span class="mt-0.5 w-8 text-brand-mint">
|
|
||||||
<img
|
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-5 w-5"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<span class="block font-serif text-lg text-brand-navy">
|
|
||||||
{doc.documentLocation}
|
|
||||||
</span>
|
|
||||||
<span class="font-sans text-xs text-gray-500"
|
|
||||||
>{m.doc_label_archive_location_original()}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- TAGS / SCHLAGWORTE -->
|
|
||||||
{#if doc.tags && doc.tags.length > 0}
|
|
||||||
<div class="group flex items-start">
|
|
||||||
<span class="mt-0.5 w-8 text-brand-mint">
|
|
||||||
<img
|
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-5 w-5"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="mb-1 flex flex-wrap gap-2">
|
|
||||||
{#each doc.tags as tag (tag.id)}
|
|
||||||
<a
|
|
||||||
href="/?tag={encodeURIComponent(tag.name)}"
|
|
||||||
class="inline-flex items-center rounded bg-brand-sand/50 px-2 py-0.5 text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
|
||||||
title={m.doc_tag_filter_title({ name: tag.name })}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<span class="font-sans text-xs text-gray-500">{m.form_label_tags()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 2. PERSONEN GROUP -->
|
|
||||||
<div>
|
|
||||||
<h3
|
|
||||||
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
|
||||||
>
|
|
||||||
{m.doc_section_persons()}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="mb-6">
|
|
||||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
|
||||||
>{m.form_label_sender()}</span
|
|
||||||
>
|
|
||||||
{#if doc.sender}
|
|
||||||
<a
|
|
||||||
href="/persons/{doc.sender.id}"
|
|
||||||
class="group block rounded border border-brand-sand bg-brand-sand/20 p-3 transition hover:border-brand-mint hover:bg-brand-mint/10"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-serif text-sm text-white"
|
|
||||||
>
|
|
||||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
class="font-serif text-brand-navy decoration-brand-mint underline-offset-2 group-hover:underline"
|
|
||||||
>
|
|
||||||
{doc.sender.firstName}
|
|
||||||
{doc.sender.lastName}
|
|
||||||
</p>
|
|
||||||
{#if doc.sender.alias}
|
|
||||||
<p class="font-sans text-xs text-gray-500">{doc.sender.alias}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<span class="font-serif text-sm text-gray-400 italic"
|
|
||||||
>{m.doc_sender_not_specified()}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
|
||||||
>{m.form_label_receivers()}</span
|
|
||||||
>
|
|
||||||
{#if doc.receivers && doc.receivers.length > 0}
|
|
||||||
<div class="space-y-2">
|
|
||||||
{#each doc.receivers as receiver (receiver.id)}
|
|
||||||
<div
|
|
||||||
class="group flex items-center justify-between rounded border border-brand-sand bg-white p-3 transition hover:border-brand-navy"
|
|
||||||
>
|
|
||||||
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-gray-100 font-serif text-xs text-gray-500"
|
|
||||||
>
|
|
||||||
{receiver.firstName[0]}{receiver.lastName[0]}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="truncate font-serif text-sm text-brand-navy group-hover:text-brand-navy"
|
|
||||||
>
|
|
||||||
{receiver.firstName}
|
|
||||||
{receiver.lastName}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{#if doc.sender}
|
|
||||||
<a
|
|
||||||
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
|
|
||||||
class="text-gray-300 transition hover:text-brand-mint"
|
|
||||||
title={m.doc_conversation_title()}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-5 w-5"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<span class="font-serif text-sm text-gray-400 italic">{m.doc_no_receivers()}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 3. INHALT GROUP -->
|
|
||||||
{#if doc.summary || doc.transcription}
|
|
||||||
<div>
|
|
||||||
<h3
|
|
||||||
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
|
||||||
>
|
|
||||||
{m.doc_section_content()}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
{#if doc.summary}
|
|
||||||
<div>
|
|
||||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
|
||||||
>{m.doc_label_summary()}</span
|
|
||||||
>
|
|
||||||
<ExpandableText text={doc.summary} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if doc.transcription}
|
|
||||||
<div>
|
|
||||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
|
||||||
>{m.form_label_transcription()}</span
|
|
||||||
>
|
|
||||||
<ExpandableText text={doc.transcription} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- 4. HISTORY GROUP -->
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between border-b border-brand-sand pb-2">
|
|
||||||
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
|
||||||
{m.history_section_title()}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onclick={toggleHistory}
|
|
||||||
class="flex items-center gap-1 rounded p-1 text-gray-400 transition hover:bg-brand-sand/50 hover:text-brand-navy"
|
|
||||||
aria-expanded={historyOpen}
|
|
||||||
aria-label={m.history_section_title()}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4 transition-transform duration-200 {historyOpen ? 'rotate-180' : ''}"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if historyOpen}
|
|
||||||
<div class="mt-4 space-y-3">
|
|
||||||
{#if historyLoading}
|
|
||||||
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
|
||||||
{:else if versions.length === 0}
|
|
||||||
<p class="font-serif text-sm text-gray-400 italic">{m.history_empty()}</p>
|
|
||||||
{:else}
|
|
||||||
<!-- Compare mode toggle -->
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<button
|
|
||||||
onclick={() => {
|
|
||||||
compareMode = !compareMode;
|
|
||||||
diffEntries = [];
|
|
||||||
noDiff = false;
|
|
||||||
selectedVersionId = null;
|
|
||||||
}}
|
|
||||||
class="font-sans text-xs font-medium transition {compareMode
|
|
||||||
? 'text-brand-navy underline'
|
|
||||||
: 'text-gray-400 hover:text-brand-navy'}"
|
|
||||||
>
|
|
||||||
{m.history_compare_mode()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if compareMode}
|
|
||||||
<!-- Compare selects -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="compare-a"
|
|
||||||
class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
|
|
||||||
>{m.history_compare_select_a()}</label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
id="compare-a"
|
|
||||||
bind:value={compareA}
|
|
||||||
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">—</option>
|
|
||||||
{#each versions as v, i (v.id)}
|
|
||||||
<option value={v.id}>{versionLabel(v, i)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="compare-b"
|
|
||||||
class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
|
|
||||||
>{m.history_compare_select_b()}</label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
id="compare-b"
|
|
||||||
bind:value={compareB}
|
|
||||||
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">—</option>
|
|
||||||
{#each versions as v, i (v.id)}
|
|
||||||
<option value={v.id}>{versionLabel(v, i)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onclick={applyCompare}
|
|
||||||
disabled={!compareA || !compareB || compareA === compareB}
|
|
||||||
class="w-full rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white transition hover:bg-brand-navy/80 disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{m.history_compare_apply()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- Version list -->
|
|
||||||
<ul class="divide-y divide-brand-sand">
|
|
||||||
{#each versions as v, i (v.id)}
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
onclick={() => selectVersion(v.id)}
|
|
||||||
data-testid="history-version"
|
|
||||||
class="w-full py-2 text-left transition hover:bg-brand-sand/30 {selectedVersionId ===
|
|
||||||
v.id
|
|
||||||
? 'border-l-2 border-brand-mint pl-2'
|
|
||||||
: 'pl-0'}"
|
|
||||||
>
|
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
|
||||||
<span class="font-sans text-xs font-medium text-brand-navy">
|
|
||||||
Version {i + 1}
|
|
||||||
</span>
|
|
||||||
<span class="font-sans text-[10px] text-gray-400">
|
|
||||||
{formatDateTime(v.savedAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="font-sans text-[11px] text-gray-500">{v.editorName}</span>
|
|
||||||
{#if v.changedFields && v.changedFields.length > 0}
|
|
||||||
<div class="mt-1 flex flex-wrap gap-1">
|
|
||||||
{#each v.changedFields as field (field)}
|
|
||||||
<span
|
|
||||||
class="rounded bg-brand-sand/50 px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-gray-500 uppercase"
|
|
||||||
>
|
|
||||||
{fieldLabels[field] ? fieldLabels[field]() : field}
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Diff panel -->
|
|
||||||
{#if diffLoading}
|
|
||||||
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
|
||||||
{:else if noDiff}
|
|
||||||
<div
|
|
||||||
data-testid="history-diff"
|
|
||||||
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
|
|
||||||
>
|
|
||||||
{m.history_diff_no_changes()}
|
|
||||||
</div>
|
|
||||||
{:else if diffEntries.length > 0}
|
|
||||||
<div
|
|
||||||
data-testid="history-diff"
|
|
||||||
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4"
|
|
||||||
>
|
|
||||||
{#each diffEntries as entry (entry.field)}
|
|
||||||
<div>
|
|
||||||
<span
|
|
||||||
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
|
|
||||||
>{entry.label}</span
|
|
||||||
>
|
|
||||||
{#if entry.kind === 'text'}
|
|
||||||
<p class="font-serif text-sm leading-relaxed">
|
|
||||||
{#each entry.parts as part, partIdx (partIdx)}
|
|
||||||
{#if part.added}
|
|
||||||
<span class="bg-green-50 text-green-700">{part.value}</span>
|
|
||||||
{:else if part.removed}
|
|
||||||
<span class="bg-red-50 text-red-600 line-through">{part.value}</span
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<span>{part.value}</span>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</p>
|
|
||||||
{:else if entry.kind === 'scalar'}
|
|
||||||
<div class="flex items-center gap-2 font-serif text-sm">
|
|
||||||
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
|
||||||
<svg
|
|
||||||
class="h-3 w-3 flex-shrink-0 text-gray-400"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-green-700">{entry.newVal || '—'}</span>
|
|
||||||
</div>
|
|
||||||
{:else if entry.kind === 'relation'}
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
{#each entry.removed as item (item)}
|
|
||||||
<span
|
|
||||||
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
|
||||||
>{item}</span
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
{#each entry.added as item (item)}
|
|
||||||
<span
|
|
||||||
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
|
||||||
>{item}</span
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 5. DISKUSSION -->
|
|
||||||
<div>
|
|
||||||
<div class="border-b border-brand-sand pb-2">
|
|
||||||
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
|
||||||
{m.comment_section_title()}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<CommentThread
|
|
||||||
documentId={doc.id}
|
|
||||||
initialComments={data.comments ?? []}
|
|
||||||
canComment={canComment}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
canAdmin={canAdmin}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="border-t border-brand-sand pt-4 font-sans text-[10px] text-gray-400">
|
|
||||||
<p class="truncate">ID: {doc.id}</p>
|
|
||||||
<p class="mt-1 truncate">{doc.originalFilename}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- RIGHT: PREVIEW AREA -->
|
|
||||||
<main class="relative flex flex-1 flex-col items-center justify-center bg-[#2A2A2A]">
|
|
||||||
{#if isLoading}
|
|
||||||
<div class="flex flex-col items-center text-brand-mint">
|
|
||||||
<svg
|
|
||||||
class="mb-4 h-8 w-8 animate-spin"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<span class="font-sans text-sm tracking-wide">{m.doc_loading()}</span>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="px-4 text-center text-gray-400">
|
|
||||||
<p class="mb-2 font-serif">{error}</p>
|
|
||||||
{#if doc.filePath}
|
|
||||||
<a
|
|
||||||
href={`/api/documents/${doc.id}/file`}
|
|
||||||
target="_blank"
|
|
||||||
class="text-sm underline hover:text-white"
|
|
||||||
>
|
|
||||||
{m.doc_download_link()}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else if !doc.filePath}
|
|
||||||
<div class="flex flex-col items-center text-gray-400">
|
|
||||||
<div class="mb-6 rounded-full bg-white/5 p-8">
|
|
||||||
<img
|
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-12 w-12 opacity-50 invert"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
|
||||||
</div>
|
|
||||||
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
|
|
||||||
<PdfViewer
|
|
||||||
url={fileUrl}
|
|
||||||
documentId={doc.id}
|
|
||||||
canAnnotate={data.canAnnotate}
|
|
||||||
canComment={canComment}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
canAdmin={canAdmin}
|
|
||||||
documentFileHash={doc.fileHash ?? null}
|
|
||||||
/>
|
|
||||||
{:else if fileUrl}
|
|
||||||
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
|
||||||
<img
|
|
||||||
src={fileUrl}
|
|
||||||
alt={m.doc_image_alt()}
|
|
||||||
class="max-h-full max-w-full object-contain shadow-2xl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DocumentBottomPanel
|
||||||
|
doc={doc}
|
||||||
|
comments={(data.comments ?? []) as never[]}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
bind:open={panelOpen}
|
||||||
|
bind:height={panelHeight}
|
||||||
|
bind:activeTab={activeTab}
|
||||||
|
activeAnnotationId={activeAnnotationId}
|
||||||
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user