Compare commits
38 Commits
feat/62-do
...
db103ca1ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db103ca1ab | ||
|
|
3ec680b812 | ||
|
|
50e3f948c7 | ||
|
|
bbfef9a22d | ||
|
|
332b5b3c40 | ||
|
|
29a71f4421 | ||
|
|
eade2aa48a | ||
|
|
bda3cdf9af | ||
|
|
1765ffce01 | ||
|
|
399fa36f60 | ||
|
|
51a0eb76de | ||
|
|
162c58e8c5 | ||
|
|
e4539ed0f0 | ||
|
|
caba89dacc | ||
|
|
e83ba9b681 | ||
|
|
93befbd8da | ||
|
|
9aa98b4fb6 | ||
|
|
dd360ade8b | ||
|
|
f71712ab4b | ||
|
|
10783fdb55 | ||
|
|
5ea5590c89 | ||
|
|
142f296255 | ||
|
|
c19f7b3b1a | ||
|
|
db9d8ed457 | ||
|
|
65457a5650 | ||
|
|
1eb2659ba0 | ||
|
|
f18649fb79 | ||
|
|
a392e85f43 | ||
|
|
c9b4e6dad4 | ||
|
|
8519fbb48a | ||
|
|
ee85ce4668 | ||
|
|
ecfd80bf9a | ||
|
|
63013cc86a | ||
|
|
9e2419a48e | ||
|
|
00195dc8db | ||
|
|
0ec86220d3 | ||
|
|
7fbc33b32d | ||
|
|
93f57477cd |
@@ -84,6 +84,14 @@ public class DataInitializer {
|
|||||||
TagRepository tagRepo,
|
TagRepository tagRepo,
|
||||||
PasswordEncoder passwordEncoder) {
|
PasswordEncoder passwordEncoder) {
|
||||||
return args -> {
|
return args -> {
|
||||||
|
// Always reset the admin password to the configured value so a failed password-reset
|
||||||
|
// test from a previous run can never leave the account locked out.
|
||||||
|
userRepository.findByUsername(adminUsername).ifPresent(admin -> {
|
||||||
|
admin.setPassword(passwordEncoder.encode(adminPassword));
|
||||||
|
userRepository.save(admin);
|
||||||
|
log.info("E2E seed: Admin-Passwort auf konfigurierten Wert zurückgesetzt.");
|
||||||
|
});
|
||||||
|
|
||||||
// Always ensure the read-only test user exists, even when seed data was already loaded.
|
// Always ensure the read-only test user exists, even when seed data was already loaded.
|
||||||
if (userRepository.findByUsername("reader").isEmpty()) {
|
if (userRepository.findByUsername("reader").isEmpty()) {
|
||||||
log.info("E2E seed: Erstelle 'reader'-Testbenutzer...");
|
log.info("E2E seed: Erstelle 'reader'-Testbenutzer...");
|
||||||
|
|||||||
@@ -41,4 +41,10 @@ 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,10 +4,12 @@ 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;
|
||||||
@@ -23,6 +25,7 @@ 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
|
||||||
@@ -38,7 +41,8 @@ public class AnnotationController {
|
|||||||
@RequestBody CreateAnnotationDTO dto,
|
@RequestBody CreateAnnotationDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
UUID userId = resolveUserId(authentication);
|
UUID userId = resolveUserId(authentication);
|
||||||
return annotationService.createAnnotation(documentId, dto, userId);
|
Document doc = documentService.getDocumentById(documentId);
|
||||||
|
return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash());
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{annotationId}")
|
@DeleteMapping("/{annotationId}")
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
||||||
@@ -103,6 +105,40 @@ public class DocumentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- QUICK UPLOAD ---
|
||||||
|
|
||||||
|
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
||||||
|
"application/pdf", "image/jpeg", "image/png", "image/tiff");
|
||||||
|
|
||||||
|
public record QuickUploadResult(List<Document> created, List<String> errors) {}
|
||||||
|
|
||||||
|
@PostMapping(value = "/quick-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public QuickUploadResult quickUpload(
|
||||||
|
@RequestPart(value = "files", required = false) List<MultipartFile> files) {
|
||||||
|
List<Document> created = new ArrayList<>();
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
if (files == null || files.isEmpty()) {
|
||||||
|
return new QuickUploadResult(created, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (MultipartFile file : files) {
|
||||||
|
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||||
|
errors.add(file.getOriginalFilename() + ": unsupported file type");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
created.add(documentService.storeDocument(file));
|
||||||
|
} catch (Exception e) {
|
||||||
|
errors.add(file.getOriginalFilename() + ": " + e.getMessage());
|
||||||
|
log.warn("Quick upload failed for file {}: {}", file.getOriginalFilename(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new QuickUploadResult(created, errors);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
public ResponseEntity<List<Document>> search(
|
public ResponseEntity<List<Document>> search(
|
||||||
@RequestParam(required = false) String q,
|
@RequestParam(required = false) String q,
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ 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,6 +49,9 @@ 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,4 +14,6 @@ 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,6 +37,8 @@ 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) {
|
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) {
|
||||||
List<DocumentAnnotation> existing =
|
List<DocumentAnnotation> existing =
|
||||||
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
|
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ 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();
|
||||||
|
|
||||||
@@ -61,6 +62,14 @@ 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,6 +18,8 @@ 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;
|
||||||
@@ -38,6 +40,7 @@ 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.
|
||||||
@@ -58,16 +61,17 @@ public class DocumentService {
|
|||||||
} else {
|
} else {
|
||||||
document = Document.builder()
|
document = Document.builder()
|
||||||
.originalFilename(originalFilename)
|
.originalFilename(originalFilename)
|
||||||
.title(originalFilename)
|
.title(stripExtension(originalFilename))
|
||||||
.status(DocumentStatus.UPLOADED)
|
.status(DocumentStatus.UPLOADED)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Delegate Storage to FileService
|
// 2. Delegate Storage to FileService
|
||||||
String s3Key = fileService.uploadFile(file, originalFilename);
|
FileService.UploadResult upload = fileService.uploadFile(file, originalFilename);
|
||||||
|
|
||||||
// 3. Update Database
|
// 3. Update Database
|
||||||
document.setFilePath(s3Key);
|
document.setFilePath(upload.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);
|
||||||
@@ -120,8 +124,9 @@ public class DocumentService {
|
|||||||
|
|
||||||
// Datei
|
// Datei
|
||||||
if (file != null && !file.isEmpty()) {
|
if (file != null && !file.isEmpty()) {
|
||||||
String s3Key = fileService.uploadFile(file, file.getOriginalFilename());
|
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
|
||||||
doc.setFilePath(s3Key);
|
doc.setFilePath(upload.s3Key());
|
||||||
|
doc.setFileHash(upload.fileHash());
|
||||||
doc.setContentType(file.getContentType());
|
doc.setContentType(file.getContentType());
|
||||||
doc.setStatus(DocumentStatus.UPLOADED);
|
doc.setStatus(DocumentStatus.UPLOADED);
|
||||||
}
|
}
|
||||||
@@ -170,12 +175,9 @@ 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()) {
|
||||||
// Alte Datei könnte man hier theoretisch löschen (optional)
|
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||||
|
doc.setFilePath(upload.s3Key());
|
||||||
// Neue Datei hochladen
|
doc.setFileHash(upload.fileHash());
|
||||||
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);
|
||||||
@@ -283,4 +285,45 @@ 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 stripExtension(String filename) {
|
||||||
|
if (filename == null) return null;
|
||||||
|
int dot = filename.lastIndexOf('.');
|
||||||
|
return dot > 0 ? filename.substring(0, dot) : filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,6 +13,9 @@ 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
|
||||||
@@ -29,10 +32,14 @@ public class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads a file to S3/MinIO and returns the generated object key.
|
* Uploads a file to S3/MinIO.
|
||||||
|
* 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 String uploadFile(MultipartFile file, String originalFilename) throws IOException {
|
public UploadResult uploadFile(MultipartFile file, String originalFilename) throws IOException {
|
||||||
// Generate secure unique path: "documents/UUID_filename"
|
byte[] bytes = file.getBytes();
|
||||||
|
String fileHash = sha256Hex(bytes);
|
||||||
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
|
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -42,11 +49,10 @@ public class FileService {
|
|||||||
.contentType(file.getContentType())
|
.contentType(file.getContentType())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
s3Client.putObject(putObjectRequest,
|
s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes));
|
||||||
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
|
|
||||||
|
|
||||||
log.info("Uploaded file to S3: {}", s3Key);
|
log.info("Uploaded file to S3: {} (hash={})", s3Key, fileHash);
|
||||||
return s3Key;
|
return new UploadResult(s3Key, fileHash);
|
||||||
} 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);
|
||||||
@@ -58,32 +64,72 @@ 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);
|
||||||
|
|
||||||
// Use whatever content type S3 has stored (set at upload time)
|
String contentType = s3Object.response().contentType();
|
||||||
String contentType = s3Object.response().contentType();
|
if (contentType == null || contentType.isBlank()) {
|
||||||
if (contentType == null || contentType.isBlank()) {
|
contentType = "application/octet-stream";
|
||||||
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); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- 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,4 +58,29 @@ 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,10 +4,12 @@ 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;
|
||||||
@@ -36,6 +38,7 @@ 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;
|
||||||
|
|
||||||
@@ -85,7 +88,8 @@ 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(annotationService.createAnnotation(any(), any(), any())).thenReturn(saved);
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
|
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)
|
||||||
@@ -97,7 +101,8 @@ 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(annotationService.createAnnotation(any(), any(), any()))
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
|
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")
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import java.util.Collections;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.containsString;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
@@ -121,6 +122,50 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/quick-upload ────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void quickUpload_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void quickUpload_returns403_whenMissingWritePermission() throws Exception {
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_returns200_withValidPdfFile() throws Exception {
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
|
||||||
|
when(documentService.storeDocument(any())).thenReturn(doc);
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created[0].title").value("scan001"))
|
||||||
|
.andExpect(jsonPath("$.errors").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_skipsUnsupportedFileType_andReturnsError() throws Exception {
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.errors[0]").value(containsString("report.docx")));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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))
|
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null))
|
||||||
.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);
|
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
assertThat(result).isEqualTo(saved);
|
||||||
verify(annotationRepository).save(any());
|
verify(annotationRepository).save(any());
|
||||||
@@ -117,6 +117,35 @@ 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
|
||||||
@@ -128,4 +157,30 @@ 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,6 +21,7 @@ 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)
|
||||||
@@ -31,6 +32,7 @@ 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 ──────────────────────────────────────────────────────
|
||||||
@@ -135,6 +137,48 @@ 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
|
||||||
@@ -167,4 +211,97 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
verify(documentVersionService).recordVersion(any(Document.class));
|
verify(documentVersionService).recordVersion(any(Document.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── storeDocument ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_setsTitle_withoutFileExtension_forNewDocument() throws Exception {
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123");
|
||||||
|
Document saved = Document.builder().id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
|
||||||
|
|
||||||
|
when(documentRepository.findByOriginalFilename("scan001.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenReturn(saved);
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
|
||||||
|
documentService.storeDocument(file);
|
||||||
|
|
||||||
|
verify(documentRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getTitle()).isEqualTo("scan001");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_preservesExistingTitle_whenPlaceholderAlreadyExists() throws Exception {
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123");
|
||||||
|
Document placeholder = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).title("Brief an Oma").originalFilename("scan001.pdf")
|
||||||
|
.status(org.raddatz.familienarchiv.model.DocumentStatus.PLACEHOLDER).build();
|
||||||
|
|
||||||
|
when(documentRepository.findByOriginalFilename("scan001.pdf")).thenReturn(Optional.of(placeholder));
|
||||||
|
when(documentRepository.save(any())).thenReturn(placeholder);
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||||
|
|
||||||
|
documentService.storeDocument(file);
|
||||||
|
|
||||||
|
assertThat(placeholder.getTitle()).isEqualTo("Brief an Oma");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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,3 +216,35 @@ 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -347,13 +347,142 @@ 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 sees a disabled Annotieren button', async ({ page }) => {
|
test('read-only user does not see the 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');
|
||||||
@@ -365,12 +494,10 @@ 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 });
|
|
||||||
|
|
||||||
const disabledBtn = page.getByRole('button', { name: /annotieren/i });
|
// Reader users do not have ANNOTATE_ALL permission — the button must not be shown at all.
|
||||||
await expect(disabledBtn).toBeVisible({ timeout: 5000 });
|
const annotateBtn = page.getByRole('button', { name: /annotieren/i });
|
||||||
await expect(disabledBtn).toBeDisabled();
|
await expect(annotateBtn).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
|
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
|
||||||
});
|
});
|
||||||
|
|||||||
21
frontend/e2e/fixtures/minimal2.pdf
Normal file
21
frontend/e2e/fixtures/minimal2.pdf
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
%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
|
||||||
73
frontend/e2e/theme.spec.ts
Normal file
73
frontend/e2e/theme.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Theme toggle', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Clear any saved theme preference before each test
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(() => localStorage.removeItem('theme'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle button is visible in the header', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(
|
||||||
|
page.getByRole('banner').getByRole('button', { name: /dark mode|light mode/i })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking the toggle switches to dark mode', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
const html = page.locator('html');
|
||||||
|
await expect(html).not.toHaveAttribute('data-theme', 'dark');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('banner')
|
||||||
|
.getByRole('button', { name: /dark mode/i })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(html).toHaveAttribute('data-theme', 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking the toggle again switches back to light mode', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('banner')
|
||||||
|
.getByRole('button', { name: /dark mode/i })
|
||||||
|
.click();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('banner')
|
||||||
|
.getByRole('button', { name: /light mode/i })
|
||||||
|
.click();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('theme persists after page reload', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('banner')
|
||||||
|
.getByRole('button', { name: /dark mode/i })
|
||||||
|
.click();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saved theme is applied before first paint (no flash)', async ({ page }) => {
|
||||||
|
// Set dark theme in localStorage before navigating
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(() => localStorage.setItem('theme', 'dark'));
|
||||||
|
|
||||||
|
// Intercept the initial HTML to verify data-theme is set immediately
|
||||||
|
await page.goto('/');
|
||||||
|
const theme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
|
expect(theme).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"$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.",
|
||||||
@@ -241,6 +242,10 @@
|
|||||||
"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.",
|
||||||
@@ -257,5 +262,13 @@
|
|||||||
"doc_panel_tab_history": "Verlauf",
|
"doc_panel_tab_history": "Verlauf",
|
||||||
"doc_panel_annotate": "Annotieren",
|
"doc_panel_annotate": "Annotieren",
|
||||||
"doc_panel_annotate_stop": "Fertig",
|
"doc_panel_annotate_stop": "Fertig",
|
||||||
"doc_panel_annotation_thread_title": "Annotation"
|
"doc_panel_annotation_thread_title": "Annotation",
|
||||||
|
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
||||||
|
"pdf_annotations_show": "Annotierungen anzeigen",
|
||||||
|
"pdf_annotations_hide": "Annotierungen verbergen",
|
||||||
|
"upload_drop_hint": "Dateien ablegen oder auswählen",
|
||||||
|
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||||
|
"upload_success": "{count} Dokument(e) erstellt",
|
||||||
|
"upload_invalid_type": "{filename}: Dateiformat nicht unterstützt",
|
||||||
|
"upload_error": "Fehler beim Hochladen von {filename}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"$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.",
|
||||||
@@ -241,6 +242,10 @@
|
|||||||
"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.",
|
||||||
@@ -257,5 +262,13 @@
|
|||||||
"doc_panel_tab_history": "History",
|
"doc_panel_tab_history": "History",
|
||||||
"doc_panel_annotate": "Annotate",
|
"doc_panel_annotate": "Annotate",
|
||||||
"doc_panel_annotate_stop": "Done",
|
"doc_panel_annotate_stop": "Done",
|
||||||
"doc_panel_annotation_thread_title": "Annotation"
|
"doc_panel_annotation_thread_title": "Annotation",
|
||||||
|
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
||||||
|
"pdf_annotations_show": "Show annotations",
|
||||||
|
"pdf_annotations_hide": "Hide annotations",
|
||||||
|
"upload_drop_hint": "Drop files or click to select",
|
||||||
|
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||||
|
"upload_success": "{count} document(s) created",
|
||||||
|
"upload_invalid_type": "{filename}: unsupported file format",
|
||||||
|
"upload_error": "Error uploading {filename}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"$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.",
|
||||||
@@ -241,6 +242,10 @@
|
|||||||
"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.",
|
||||||
@@ -257,5 +262,13 @@
|
|||||||
"doc_panel_tab_history": "Historial",
|
"doc_panel_tab_history": "Historial",
|
||||||
"doc_panel_annotate": "Anotar",
|
"doc_panel_annotate": "Anotar",
|
||||||
"doc_panel_annotate_stop": "Listo",
|
"doc_panel_annotate_stop": "Listo",
|
||||||
"doc_panel_annotation_thread_title": "Anotación"
|
"doc_panel_annotation_thread_title": "Anotación",
|
||||||
|
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
||||||
|
"pdf_annotations_show": "Mostrar anotaciones",
|
||||||
|
"pdf_annotations_hide": "Ocultar anotaciones",
|
||||||
|
"upload_drop_hint": "Soltar archivos o hacer clic para seleccionar",
|
||||||
|
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||||
|
"upload_success": "{count} documento(s) creado(s)",
|
||||||
|
"upload_invalid_type": "{filename}: formato de archivo no admitido",
|
||||||
|
"upload_error": "Error al subir {filename}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var t = localStorage.getItem('theme');
|
||||||
|
if (t === 'dark' || t === 'light') document.documentElement.setAttribute('data-theme', t);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -25,16 +25,16 @@ let {
|
|||||||
|
|
||||||
<!-- Desktop / tablet panel (≥ sm): absolute overlay on the right side -->
|
<!-- Desktop / tablet panel (≥ sm): absolute overlay on the right side -->
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 right-0 z-50 hidden h-full w-80 flex-col border-l border-brand-sand bg-white shadow-2xl sm:flex"
|
class="absolute top-0 right-0 z-50 hidden h-full w-80 flex-col border-l border-line bg-surface shadow-2xl sm:flex"
|
||||||
>
|
>
|
||||||
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
|
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
|
||||||
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||||
{m.comment_panel_title()}
|
{m.comment_panel_title()}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
aria-label={m.comment_panel_close()}
|
aria-label={m.comment_panel_close()}
|
||||||
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -60,15 +60,15 @@ let {
|
|||||||
<div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div>
|
<div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div>
|
||||||
|
|
||||||
<!-- Slide-up panel -->
|
<!-- Slide-up panel -->
|
||||||
<div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-white shadow-2xl">
|
<div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-surface shadow-2xl">
|
||||||
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
|
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
|
||||||
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||||
{m.comment_panel_title()}
|
{m.comment_panel_title()}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
aria-label={m.comment_panel_close()}
|
aria-label={m.comment_panel_close()}
|
||||||
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
|||||||
65
frontend/src/lib/components/AnnotationSidePanel.svelte
Normal file
65
frontend/src/lib/components/AnnotationSidePanel.svelte
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import CommentThread from './CommentThread.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
documentId: string;
|
||||||
|
activeAnnotationId: string | null;
|
||||||
|
activeAnnotationPage: number | null;
|
||||||
|
canComment: boolean;
|
||||||
|
currentUserId: string | null;
|
||||||
|
canAdmin: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
documentId,
|
||||||
|
activeAnnotationId,
|
||||||
|
activeAnnotationPage,
|
||||||
|
canComment,
|
||||||
|
currentUserId,
|
||||||
|
canAdmin,
|
||||||
|
onClose
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const visible = $derived(activeAnnotationId !== null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 right-0 z-10 flex w-80 flex-col border-l border-line bg-surface shadow-[-4px_0_16px_rgba(0,0,0,0.08)] transition-transform duration-200 {visible
|
||||||
|
? 'translate-x-0'
|
||||||
|
: 'pointer-events-none translate-x-full'}"
|
||||||
|
data-testid="annotation-side-panel"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
|
||||||
|
<span class="font-sans text-xs font-medium text-ink">
|
||||||
|
{m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
aria-label={m.comment_panel_close()}
|
||||||
|
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment thread -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
|
{#if activeAnnotationId}
|
||||||
|
{#key activeAnnotationId}
|
||||||
|
<CommentThread
|
||||||
|
documentId={documentId}
|
||||||
|
annotationId={activeAnnotationId}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
loadOnMount={true}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -191,26 +191,26 @@ onMount(() => {
|
|||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#each comments as thread, ti (thread.id)}
|
{#each comments as thread, ti (thread.id)}
|
||||||
<div class={ti > 0 ? 'border-t border-brand-sand pt-4' : ''}>
|
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
|
||||||
<!-- Root comment -->
|
<!-- Root comment -->
|
||||||
<div>
|
<div>
|
||||||
{#if editingId === thread.id}
|
{#if editingId === thread.id}
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||||
rows={3}
|
rows={3}
|
||||||
bind:value={editText}
|
bind:value={editText}
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
|
||||||
disabled={posting}
|
disabled={posting}
|
||||||
onclick={() => saveEdit(thread.id)}
|
onclick={() => saveEdit(thread.id)}
|
||||||
>
|
>
|
||||||
{m.btn_save()}
|
{m.btn_save()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
onclick={cancelEdit}
|
onclick={cancelEdit}
|
||||||
>
|
>
|
||||||
{m.btn_cancel()}
|
{m.btn_cancel()}
|
||||||
@@ -221,29 +221,27 @@ onMount(() => {
|
|||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="font-sans text-xs font-semibold text-brand-navy"
|
<span class="font-sans text-xs font-semibold text-ink">{thread.authorName}</span>
|
||||||
>{thread.authorName}</span
|
<span class="font-sans text-xs text-ink-3">{timeAgo(thread.createdAt)}</span>
|
||||||
>
|
|
||||||
<span class="font-sans text-xs text-gray-400">{timeAgo(thread.createdAt)}</span>
|
|
||||||
{#if wasEdited(thread)}
|
{#if wasEdited(thread)}
|
||||||
<span class="font-sans text-xs text-gray-400">
|
<span class="font-sans text-xs text-ink-3">
|
||||||
{m.comment_edited_label()}
|
{m.comment_edited_label()}
|
||||||
{timeAgo(thread.updatedAt)}
|
{timeAgo(thread.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{thread.content}</p>
|
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">{thread.content}</p>
|
||||||
</div>
|
</div>
|
||||||
{#if canModify(thread)}
|
{#if canModify(thread)}
|
||||||
<div class="flex shrink-0 items-center gap-2">
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
<button
|
<button
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
onclick={() => startEdit(thread)}
|
onclick={() => startEdit(thread)}
|
||||||
>
|
>
|
||||||
{m.btn_edit()}
|
{m.btn_edit()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
onclick={() => deleteComment(thread.id)}
|
onclick={() => deleteComment(thread.id)}
|
||||||
>
|
>
|
||||||
{m.btn_delete()}
|
{m.btn_delete()}
|
||||||
@@ -255,7 +253,7 @@ onMount(() => {
|
|||||||
{#if thread.replies.length === 0 && canComment}
|
{#if thread.replies.length === 0 && canComment}
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<button
|
<button
|
||||||
class="font-sans text-xs font-medium text-brand-mint transition-colors hover:text-brand-navy"
|
class="font-sans text-xs font-medium text-accent transition-colors hover:text-ink"
|
||||||
onclick={() => startReply(thread.id)}
|
onclick={() => startReply(thread.id)}
|
||||||
>
|
>
|
||||||
{m.comment_btn_reply()}
|
{m.comment_btn_reply()}
|
||||||
@@ -267,24 +265,24 @@ onMount(() => {
|
|||||||
|
|
||||||
<!-- Replies -->
|
<!-- Replies -->
|
||||||
{#each thread.replies as reply, ri (reply.id)}
|
{#each thread.replies as reply, ri (reply.id)}
|
||||||
<div class="mt-3 ml-6 border-l-2 border-brand-sand pl-4">
|
<div class="mt-3 ml-6 border-l-2 border-line pl-4">
|
||||||
{#if editingId === reply.id}
|
{#if editingId === reply.id}
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||||
rows={3}
|
rows={3}
|
||||||
bind:value={editText}
|
bind:value={editText}
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
|
||||||
disabled={posting}
|
disabled={posting}
|
||||||
onclick={() => saveEdit(reply.id)}
|
onclick={() => saveEdit(reply.id)}
|
||||||
>
|
>
|
||||||
{m.btn_save()}
|
{m.btn_save()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
onclick={cancelEdit}
|
onclick={cancelEdit}
|
||||||
>
|
>
|
||||||
{m.btn_cancel()}
|
{m.btn_cancel()}
|
||||||
@@ -295,29 +293,27 @@ onMount(() => {
|
|||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="font-sans text-xs font-semibold text-brand-navy"
|
<span class="font-sans text-xs font-semibold text-ink">{reply.authorName}</span>
|
||||||
>{reply.authorName}</span
|
<span class="font-sans text-xs text-ink-3">{timeAgo(reply.createdAt)}</span>
|
||||||
>
|
|
||||||
<span class="font-sans text-xs text-gray-400">{timeAgo(reply.createdAt)}</span>
|
|
||||||
{#if wasEdited(reply)}
|
{#if wasEdited(reply)}
|
||||||
<span class="font-sans text-xs text-gray-400">
|
<span class="font-sans text-xs text-ink-3">
|
||||||
{m.comment_edited_label()}
|
{m.comment_edited_label()}
|
||||||
{timeAgo(reply.updatedAt)}
|
{timeAgo(reply.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{reply.content}</p>
|
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">{reply.content}</p>
|
||||||
</div>
|
</div>
|
||||||
{#if canModify(reply)}
|
{#if canModify(reply)}
|
||||||
<div class="flex shrink-0 items-center gap-2">
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
<button
|
<button
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
onclick={() => startEdit(reply)}
|
onclick={() => startEdit(reply)}
|
||||||
>
|
>
|
||||||
{m.btn_edit()}
|
{m.btn_edit()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
onclick={() => deleteComment(reply.id)}
|
onclick={() => deleteComment(reply.id)}
|
||||||
>
|
>
|
||||||
{m.btn_delete()}
|
{m.btn_delete()}
|
||||||
@@ -329,7 +325,7 @@ onMount(() => {
|
|||||||
{#if ri === thread.replies.length - 1 && canComment}
|
{#if ri === thread.replies.length - 1 && canComment}
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<button
|
<button
|
||||||
class="font-sans text-xs font-medium text-brand-mint transition-colors hover:text-brand-navy"
|
class="font-sans text-xs font-medium text-accent transition-colors hover:text-ink"
|
||||||
onclick={() => startReply(thread.id)}
|
onclick={() => startReply(thread.id)}
|
||||||
>
|
>
|
||||||
{m.comment_btn_reply()}
|
{m.comment_btn_reply()}
|
||||||
@@ -344,21 +340,21 @@ onMount(() => {
|
|||||||
{#if replyingTo === thread.id}
|
{#if replyingTo === thread.id}
|
||||||
<div class="mt-3 ml-6 flex flex-col gap-2">
|
<div class="mt-3 ml-6 flex flex-col gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={m.comment_placeholder()}
|
placeholder={m.comment_placeholder()}
|
||||||
bind:value={replyText}
|
bind:value={replyText}
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
|
||||||
disabled={posting}
|
disabled={posting}
|
||||||
onclick={() => postReply(thread.id)}
|
onclick={() => postReply(thread.id)}
|
||||||
>
|
>
|
||||||
{m.comment_btn_post()}
|
{m.comment_btn_post()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
onclick={cancelReply}
|
onclick={cancelReply}
|
||||||
>
|
>
|
||||||
{m.btn_cancel()}
|
{m.btn_cancel()}
|
||||||
@@ -371,17 +367,17 @@ onMount(() => {
|
|||||||
|
|
||||||
<!-- New top-level comment textarea -->
|
<!-- New top-level comment textarea -->
|
||||||
{#if canComment}
|
{#if canComment}
|
||||||
<div class={comments.length > 0 ? 'border-t border-brand-sand pt-4' : ''}>
|
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={m.comment_placeholder()}
|
placeholder={m.comment_placeholder()}
|
||||||
bind:value={newText}
|
bind:value={newText}
|
||||||
></textarea>
|
></textarea>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
|
||||||
disabled={posting || !newText.trim()}
|
disabled={posting || !newText.trim()}
|
||||||
onclick={postComment}
|
onclick={postComment}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ type Props = {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
height: number;
|
height: number;
|
||||||
activeTab: Tab;
|
activeTab: Tab;
|
||||||
activeAnnotationId: string | null;
|
|
||||||
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -60,23 +58,25 @@ let {
|
|||||||
canAdmin,
|
canAdmin,
|
||||||
open = $bindable(),
|
open = $bindable(),
|
||||||
height = $bindable(),
|
height = $bindable(),
|
||||||
activeTab = $bindable(),
|
activeTab = $bindable()
|
||||||
activeAnnotationId,
|
|
||||||
onAnnotationCommentCountChange
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
|
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
|
||||||
const DEFAULT_HEIGHT = 320;
|
|
||||||
|
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
let dragStartY = 0;
|
let dragStartY = 0;
|
||||||
let dragStartHeight = 0;
|
let dragStartHeight = 0;
|
||||||
|
|
||||||
|
function fullHeight() {
|
||||||
|
const topbar = document.querySelector('[data-topbar]');
|
||||||
|
return window.innerHeight - (topbar?.getBoundingClientRect().bottom ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
function openTab(tab: Tab) {
|
function openTab(tab: Tab) {
|
||||||
activeTab = tab;
|
activeTab = tab;
|
||||||
if (!open) {
|
if (!open) {
|
||||||
open = true;
|
open = true;
|
||||||
if (height <= MIN_HEIGHT) height = DEFAULT_HEIGHT;
|
if (height <= MIN_HEIGHT) height = fullHeight();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,14 +95,14 @@ function onDragMove(e: PointerEvent) {
|
|||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
|
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
|
||||||
const newHeight = dragStartHeight + delta;
|
const newHeight = dragStartHeight + delta;
|
||||||
const maxHeight = Math.floor(window.innerHeight * 0.8);
|
const maxHeight = fullHeight();
|
||||||
|
|
||||||
if (newHeight <= MIN_HEIGHT + 20) {
|
if (newHeight <= MIN_HEIGHT + 20) {
|
||||||
// collapsed past threshold → close
|
// collapsed past threshold → close
|
||||||
open = false;
|
open = false;
|
||||||
} else {
|
} else {
|
||||||
open = true;
|
open = true;
|
||||||
height = Math.max(DEFAULT_HEIGHT / 4, Math.min(newHeight, maxHeight));
|
height = Math.max(80, Math.min(newHeight, maxHeight));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,13 +121,13 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<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)]"
|
class="fixed right-0 bottom-0 left-0 z-30 flex flex-col border-t border-line bg-surface shadow-[0_-4px_16px_rgba(0,0,0,0.08)]"
|
||||||
style="height: {panelHeight}px"
|
style="height: {panelHeight}px"
|
||||||
data-testid="bottom-panel"
|
data-testid="bottom-panel"
|
||||||
>
|
>
|
||||||
<!-- Drag handle -->
|
<!-- Drag handle -->
|
||||||
<div
|
<div
|
||||||
class="flex h-2 shrink-0 cursor-ns-resize items-center justify-center bg-white"
|
class="flex h-2 shrink-0 cursor-ns-resize items-center justify-center bg-surface"
|
||||||
style="touch-action: none"
|
style="touch-action: none"
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-orientation="horizontal"
|
aria-orientation="horizontal"
|
||||||
@@ -137,17 +137,17 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
|||||||
onpointerup={onDragEnd}
|
onpointerup={onDragEnd}
|
||||||
onpointercancel={onDragEnd}
|
onpointercancel={onDragEnd}
|
||||||
>
|
>
|
||||||
<div class="h-1 w-12 rounded-full bg-gray-200"></div>
|
<div class="h-1 w-12 rounded-full bg-line"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab bar -->
|
<!-- Tab bar -->
|
||||||
<div class="flex shrink-0 items-center border-b border-brand-sand bg-white px-4">
|
<div class="flex shrink-0 items-center border-b border-line bg-surface px-4">
|
||||||
{#each tabs as tab (tab.id)}
|
{#each tabs as tab (tab.id)}
|
||||||
<button
|
<button
|
||||||
onclick={() => openTab(tab.id)}
|
onclick={() => openTab(tab.id)}
|
||||||
class="mr-1 px-3 py-2.5 font-sans text-xs font-medium transition-colors {activeTab === tab.id && open
|
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'
|
? 'border-b-2 border-primary text-ink'
|
||||||
: 'text-gray-400 hover:text-brand-navy'}"
|
: 'text-ink-3 hover:text-ink'}"
|
||||||
aria-pressed={activeTab === tab.id && open}
|
aria-pressed={activeTab === tab.id && open}
|
||||||
>
|
>
|
||||||
{tab.label()}
|
{tab.label()}
|
||||||
@@ -162,7 +162,7 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
|||||||
onclick={closePanel}
|
onclick={closePanel}
|
||||||
data-testid="panel-close-btn"
|
data-testid="panel-close-btn"
|
||||||
aria-label="Panel schließen"
|
aria-label="Panel schließen"
|
||||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
class="rounded p-1.5 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -181,12 +181,10 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
|||||||
{:else if activeTab === 'discussion'}
|
{:else if activeTab === 'discussion'}
|
||||||
<PanelDiscussion
|
<PanelDiscussion
|
||||||
documentId={doc.id}
|
documentId={doc.id}
|
||||||
activeAnnotationId={activeAnnotationId}
|
|
||||||
initialComments={comments}
|
initialComments={comments}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canAdmin={canAdmin}
|
canAdmin={canAdmin}
|
||||||
onAnnotationCommentCountChange={onAnnotationCommentCountChange}
|
|
||||||
/>
|
/>
|
||||||
{:else if activeTab === 'history'}
|
{:else if activeTab === 'history'}
|
||||||
<PanelHistory documentId={doc.id} />
|
<PanelHistory documentId={doc.id} />
|
||||||
|
|||||||
@@ -58,16 +58,17 @@ const compactMeta = $derived.by(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="z-20 flex shrink-0 items-center justify-between border-b border-brand-sand bg-white px-6 py-3 shadow-sm"
|
class="z-20 flex shrink-0 items-center justify-between border-b border-line bg-surface px-6 py-3 shadow-sm"
|
||||||
|
data-topbar
|
||||||
>
|
>
|
||||||
<!-- Left: back + title -->
|
<!-- Left: back + title -->
|
||||||
<div class="flex min-w-0 items-center gap-4 overflow-hidden">
|
<div class="flex min-w-0 items-center gap-4 overflow-hidden">
|
||||||
<a
|
<a
|
||||||
href="/"
|
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"
|
class="group flex shrink-0 items-center gap-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-sand transition-colors group-hover:bg-brand-mint"
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-canvas transition-colors group-hover:bg-accent"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||||
@@ -79,15 +80,15 @@ const compactMeta = $derived.by(() => {
|
|||||||
<span class="hidden sm:inline">{m.btn_back()}</span>
|
<span class="hidden sm:inline">{m.btn_back()}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="min-w-0 border-l border-gray-200 pl-4">
|
<div class="min-w-0 border-l border-line pl-4">
|
||||||
<h1
|
<h1
|
||||||
class="truncate font-serif text-base leading-tight text-brand-navy"
|
class="truncate font-serif text-base leading-tight text-ink"
|
||||||
title={doc.title ?? doc.originalFilename ?? ''}
|
title={doc.title ?? doc.originalFilename ?? ''}
|
||||||
>
|
>
|
||||||
{doc.title || doc.originalFilename}
|
{doc.title || doc.originalFilename}
|
||||||
</h1>
|
</h1>
|
||||||
{#if compactMeta}
|
{#if compactMeta}
|
||||||
<p class="truncate font-sans text-xs text-gray-500" title={compactMeta}>
|
<p class="truncate font-sans text-xs text-ink-2" title={compactMeta}>
|
||||||
{compactMeta}
|
{compactMeta}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -100,10 +101,16 @@ const compactMeta = $derived.by(() => {
|
|||||||
<button
|
<button
|
||||||
onclick={() => (annotateMode = !annotateMode)}
|
onclick={() => (annotateMode = !annotateMode)}
|
||||||
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
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
|
class="flex items-center gap-1.5 rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
|
||||||
? 'bg-brand-navy text-white'
|
? 'bg-primary text-white'
|
||||||
: 'border border-brand-navy text-brand-navy hover:bg-brand-navy hover:text-white'}"
|
: 'border border-primary text-ink hover:bg-primary hover:text-white'}"
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 {annotateMode ? 'invert' : ''}"
|
||||||
|
/>
|
||||||
{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -111,7 +118,7 @@ const compactMeta = $derived.by(() => {
|
|||||||
{#if canWrite}
|
{#if canWrite}
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}/edit"
|
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"
|
class="flex items-center gap-2 rounded border border-primary bg-transparent px-3 py-1.5 text-xs font-medium text-ink transition hover:bg-primary hover:text-white"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||||
@@ -127,7 +134,7 @@ const compactMeta = $derived.by(() => {
|
|||||||
<a
|
<a
|
||||||
href={fileUrl}
|
href={fileUrl}
|
||||||
download={doc.originalFilename}
|
download={doc.originalFilename}
|
||||||
class="rounded border border-transparent bg-brand-sand/50 p-1.5 text-brand-navy transition hover:bg-brand-mint"
|
class="rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent"
|
||||||
title={m.doc_download_title()}
|
title={m.doc_download_title()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type Doc = {
|
|||||||
id: string;
|
id: string;
|
||||||
filePath?: string | null;
|
filePath?: string | null;
|
||||||
contentType?: string | null;
|
contentType?: string | null;
|
||||||
|
fileHash?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -15,6 +16,7 @@ type Props = {
|
|||||||
error: string;
|
error: string;
|
||||||
annotateMode: boolean;
|
annotateMode: boolean;
|
||||||
activeAnnotationId: string | null;
|
activeAnnotationId: string | null;
|
||||||
|
activeAnnotationPage: number | null;
|
||||||
onAnnotationClick: (id: string) => void;
|
onAnnotationClick: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,13 +27,14 @@ let {
|
|||||||
error,
|
error,
|
||||||
annotateMode = $bindable(),
|
annotateMode = $bindable(),
|
||||||
activeAnnotationId = $bindable(),
|
activeAnnotationId = $bindable(),
|
||||||
|
activeAnnotationPage = $bindable(),
|
||||||
onAnnotationClick
|
onAnnotationClick
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute inset-0 bg-[#2A2A2A]">
|
<div class="absolute inset-0 bg-pdf-bg">
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<div class="flex h-full flex-col items-center justify-center text-brand-mint">
|
<div class="flex h-full flex-col items-center justify-center text-accent">
|
||||||
<svg
|
<svg
|
||||||
class="mb-4 h-8 w-8 animate-spin"
|
class="mb-4 h-8 w-8 animate-spin"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -49,7 +52,7 @@ let {
|
|||||||
<span class="font-sans text-sm tracking-wide">{m.doc_loading()}</span>
|
<span class="font-sans text-sm tracking-wide">{m.doc_loading()}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="flex h-full flex-col items-center justify-center px-4 text-center text-gray-400">
|
<div class="flex h-full flex-col items-center justify-center px-4 text-center text-ink-3">
|
||||||
<p class="mb-2 font-serif">{error}</p>
|
<p class="mb-2 font-serif">{error}</p>
|
||||||
{#if doc.filePath}
|
{#if doc.filePath}
|
||||||
<a
|
<a
|
||||||
@@ -62,8 +65,8 @@ let {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if !doc.filePath}
|
{:else if !doc.filePath}
|
||||||
<div class="flex h-full flex-col items-center justify-center text-gray-400">
|
<div class="flex h-full flex-col items-center justify-center text-ink-3">
|
||||||
<div class="mb-6 rounded-full bg-white/5 p-8">
|
<div class="mb-6 rounded-full bg-surface/5 p-8">
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -79,7 +82,9 @@ let {
|
|||||||
documentId={doc.id}
|
documentId={doc.id}
|
||||||
bind:annotateMode={annotateMode}
|
bind:annotateMode={annotateMode}
|
||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
|
bind:activeAnnotationPage={activeAnnotationPage}
|
||||||
onAnnotationClick={onAnnotationClick}
|
onAnnotationClick={onAnnotationClick}
|
||||||
|
documentFileHash={doc.fileHash ?? null}
|
||||||
/>
|
/>
|
||||||
{:else if fileUrl}
|
{:else if fileUrl}
|
||||||
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ $effect(() => {
|
|||||||
<div
|
<div
|
||||||
bind:this={el}
|
bind:this={el}
|
||||||
style={!expanded ? `overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${maxLines}` : ''}
|
style={!expanded ? `overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${maxLines}` : ''}
|
||||||
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
|
class="rounded border border-line bg-muted p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-ink"
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
{#if isClamped || expanded}
|
{#if isClamped || expanded}
|
||||||
<button
|
<button
|
||||||
onclick={() => (expanded = !expanded)}
|
onclick={() => (expanded = !expanded)}
|
||||||
class="mt-2 font-sans text-xs text-gray-400 transition hover:text-brand-navy"
|
class="mt-2 font-sans text-xs text-ink-3 transition hover:text-ink"
|
||||||
>
|
>
|
||||||
{expanded ? m.comp_expandable_show_less() : m.comp_expandable_show_more()}
|
{expanded ? m.comp_expandable_show_less() : m.comp_expandable_show_more()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import CommentThread from './CommentThread.svelte';
|
import CommentThread from './CommentThread.svelte';
|
||||||
|
|
||||||
type CommentReply = {
|
type CommentReply = {
|
||||||
@@ -23,63 +22,21 @@ type Comment = {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
activeAnnotationId: string | null;
|
|
||||||
initialComments: Comment[];
|
initialComments: Comment[];
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
canAdmin: boolean;
|
canAdmin: boolean;
|
||||||
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props = $props();
|
||||||
documentId,
|
|
||||||
activeAnnotationId,
|
|
||||||
initialComments,
|
|
||||||
canComment,
|
|
||||||
currentUserId,
|
|
||||||
canAdmin,
|
|
||||||
onAnnotationCommentCountChange
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-8 p-6">
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
<!-- Annotation thread (shown when an annotation is active) -->
|
<CommentThread
|
||||||
{#if activeAnnotationId}
|
documentId={documentId}
|
||||||
<div>
|
initialComments={initialComments}
|
||||||
<h4
|
canComment={canComment}
|
||||||
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
currentUserId={currentUserId}
|
||||||
>
|
canAdmin={canAdmin}
|
||||||
{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>
|
</div>
|
||||||
|
|||||||
@@ -270,11 +270,11 @@ $effect(() => {
|
|||||||
|
|
||||||
<div class="space-y-4 p-6">
|
<div class="space-y-4 p-6">
|
||||||
{#if historyLoading}
|
{#if historyLoading}
|
||||||
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
|
||||||
{:else if !historyLoaded}
|
{:else if !historyLoaded}
|
||||||
<!-- initial state before effect runs — show nothing -->
|
<!-- initial state before effect runs — show nothing -->
|
||||||
{:else if versions.length === 0}
|
{:else if versions.length === 0}
|
||||||
<p class="font-serif text-sm text-gray-400 italic">{m.history_empty()}</p>
|
<p class="font-serif text-sm text-ink-3 italic">{m.history_empty()}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Compare mode toggle -->
|
<!-- Compare mode toggle -->
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
@@ -286,8 +286,8 @@ $effect(() => {
|
|||||||
selectedVersionId = null;
|
selectedVersionId = null;
|
||||||
}}
|
}}
|
||||||
class="font-sans text-xs font-medium transition {compareMode
|
class="font-sans text-xs font-medium transition {compareMode
|
||||||
? 'text-brand-navy underline'
|
? 'text-ink underline'
|
||||||
: 'text-gray-400 hover:text-brand-navy'}"
|
: 'text-ink-3 hover:text-ink'}"
|
||||||
>
|
>
|
||||||
{m.history_compare_mode()}
|
{m.history_compare_mode()}
|
||||||
</button>
|
</button>
|
||||||
@@ -296,13 +296,13 @@ $effect(() => {
|
|||||||
{#if compareMode}
|
{#if compareMode}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<label for="compare-a" class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
|
<label for="compare-a" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
|
||||||
>{m.history_compare_select_a()}</label
|
>{m.history_compare_select_a()}</label
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
id="compare-a"
|
id="compare-a"
|
||||||
bind:value={compareA}
|
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"
|
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{#each versions as v, i (v.id)}
|
{#each versions as v, i (v.id)}
|
||||||
@@ -311,13 +311,13 @@ $effect(() => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="compare-b" class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
|
<label for="compare-b" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
|
||||||
>{m.history_compare_select_b()}</label
|
>{m.history_compare_select_b()}</label
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
id="compare-b"
|
id="compare-b"
|
||||||
bind:value={compareB}
|
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"
|
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{#each versions as v, i (v.id)}
|
{#each versions as v, i (v.id)}
|
||||||
@@ -328,38 +328,109 @@ $effect(() => {
|
|||||||
<button
|
<button
|
||||||
onclick={applyCompare}
|
onclick={applyCompare}
|
||||||
disabled={!compareA || !compareB || compareA === compareB}
|
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"
|
class="w-full rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white transition hover:bg-primary/80 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{m.history_compare_apply()}
|
{m.history_compare_apply()}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Diff panel for compare mode -->
|
||||||
|
{#if diffLoading}
|
||||||
|
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
|
||||||
|
{:else if noDiff}
|
||||||
|
<div
|
||||||
|
data-testid="history-diff"
|
||||||
|
class="rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 italic"
|
||||||
|
>
|
||||||
|
{m.history_diff_no_changes()}
|
||||||
|
</div>
|
||||||
|
{:else if diffEntries.length > 0}
|
||||||
|
<div
|
||||||
|
data-testid="history-diff"
|
||||||
|
class="space-y-4 rounded-sm border border-line bg-surface p-4"
|
||||||
|
>
|
||||||
|
{#each diffEntries as entry (entry.field)}
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 uppercase"
|
||||||
|
>{entry.label}</span
|
||||||
|
>
|
||||||
|
{#if entry.kind === 'text'}
|
||||||
|
<p class="font-serif text-sm leading-relaxed">
|
||||||
|
{#each entry.parts as part, partIdx (partIdx)}
|
||||||
|
{#if part.added}
|
||||||
|
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||||
|
{:else if part.removed}
|
||||||
|
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
|
||||||
|
{:else}
|
||||||
|
<span>{part.value}</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
{:else if entry.kind === 'scalar'}
|
||||||
|
<div class="flex items-center gap-2 font-serif text-sm">
|
||||||
|
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||||
|
<svg
|
||||||
|
class="h-3 w-3 flex-shrink-0 text-ink-3"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||||
|
</div>
|
||||||
|
{:else if entry.kind === 'relation'}
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each entry.removed as item (item)}
|
||||||
|
<span
|
||||||
|
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||||
|
>{item}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{#each entry.added as item (item)}
|
||||||
|
<span
|
||||||
|
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||||
|
>{item}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Version list -->
|
<!-- Version list with inline diff below each selected item -->
|
||||||
<ul class="divide-y divide-brand-sand">
|
<ul class="divide-brand-sand divide-y">
|
||||||
{#each versions as v, i (v.id)}
|
{#each versions as v, i (v.id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
onclick={() => selectVersion(v.id)}
|
onclick={() => selectVersion(v.id)}
|
||||||
data-testid="history-version"
|
data-testid="history-version"
|
||||||
class="w-full py-2 text-left transition hover:bg-brand-sand/30 {selectedVersionId ===
|
class="w-full py-2 text-left transition hover:bg-muted {selectedVersionId ===
|
||||||
v.id
|
v.id
|
||||||
? 'border-l-2 border-brand-mint pl-2'
|
? 'border-l-2 border-accent pl-2'
|
||||||
: 'pl-0'}"
|
: 'pl-0'}"
|
||||||
>
|
>
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
<span class="font-sans text-xs font-medium text-brand-navy">
|
<span class="font-sans text-xs font-medium text-ink">
|
||||||
Version {i + 1}
|
Version {i + 1}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-sans text-[10px] text-gray-400">
|
<span class="font-sans text-[10px] text-ink-3">
|
||||||
{formatDateTime(v.savedAt)}
|
{formatDateTime(v.savedAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-sans text-[11px] text-gray-500">{v.editorName}</span>
|
<span class="font-sans text-[11px] text-ink-2">{v.editorName}</span>
|
||||||
{#if v.changedFields && v.changedFields.length > 0}
|
{#if v.changedFields && v.changedFields.length > 0}
|
||||||
<div class="mt-1 flex flex-wrap gap-1">
|
<div class="mt-1 flex flex-wrap gap-1">
|
||||||
{#each v.changedFields as field (field)}
|
{#each v.changedFields as field (field)}
|
||||||
<span
|
<span
|
||||||
class="rounded bg-brand-sand/50 px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-gray-500 uppercase"
|
class="rounded bg-muted px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-ink-2 uppercase"
|
||||||
>
|
>
|
||||||
{fieldLabels[field] ? fieldLabels[field]() : field}
|
{fieldLabels[field] ? fieldLabels[field]() : field}
|
||||||
</span>
|
</span>
|
||||||
@@ -367,80 +438,82 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Diff shown inline below the selected version -->
|
||||||
|
{#if selectedVersionId === v.id}
|
||||||
|
{#if diffLoading}
|
||||||
|
<p class="pb-3 pl-2 font-sans text-xs text-ink-3">{m.history_loading()}</p>
|
||||||
|
{:else if noDiff}
|
||||||
|
<div
|
||||||
|
data-testid="history-diff"
|
||||||
|
class="mb-2 rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 italic"
|
||||||
|
>
|
||||||
|
{m.history_diff_no_changes()}
|
||||||
|
</div>
|
||||||
|
{:else if diffEntries.length > 0}
|
||||||
|
<div
|
||||||
|
data-testid="history-diff"
|
||||||
|
class="mb-2 space-y-4 rounded-sm border border-line bg-surface p-4"
|
||||||
|
>
|
||||||
|
{#each diffEntries as entry (entry.field)}
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 uppercase"
|
||||||
|
>{entry.label}</span
|
||||||
|
>
|
||||||
|
{#if entry.kind === 'text'}
|
||||||
|
<p class="font-serif text-sm leading-relaxed">
|
||||||
|
{#each entry.parts as part, partIdx (partIdx)}
|
||||||
|
{#if part.added}
|
||||||
|
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||||
|
{:else if part.removed}
|
||||||
|
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
|
||||||
|
{:else}
|
||||||
|
<span>{part.value}</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
{:else if entry.kind === 'scalar'}
|
||||||
|
<div class="flex items-center gap-2 font-serif text-sm">
|
||||||
|
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||||
|
<svg
|
||||||
|
class="h-3 w-3 flex-shrink-0 text-ink-3"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||||
|
</div>
|
||||||
|
{:else if entry.kind === 'relation'}
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each entry.removed as item (item)}
|
||||||
|
<span
|
||||||
|
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||||
|
>{item}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{#each entry.added as item (item)}
|
||||||
|
<span
|
||||||
|
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||||
|
>{item}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ let { doc }: { doc: Doc } = $props();
|
|||||||
<!-- DETAILS GROUP -->
|
<!-- DETAILS GROUP -->
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||||
>
|
>
|
||||||
{m.doc_section_details()}
|
{m.doc_section_details()}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<!-- Date -->
|
<!-- Date -->
|
||||||
<div class="flex items-start">
|
<div class="flex items-start">
|
||||||
<span class="mt-0.5 w-8 text-brand-mint">
|
<span class="mt-0.5 w-8 text-accent">
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -37,16 +37,16 @@ let { doc }: { doc: Doc } = $props();
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<span class="block font-serif text-lg text-brand-navy">
|
<span class="block font-serif text-lg text-ink">
|
||||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-sans text-xs text-gray-500">{m.doc_label_document_date()}</span>
|
<span class="font-sans text-xs text-ink-2">{m.doc_label_document_date()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Creation Location -->
|
<!-- Creation Location -->
|
||||||
<div class="flex items-start">
|
<div class="flex items-start">
|
||||||
<span class="mt-0.5 w-8 text-brand-mint">
|
<span class="mt-0.5 w-8 text-accent">
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -55,17 +55,17 @@ let { doc }: { doc: Doc } = $props();
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<span class="block font-serif text-lg text-brand-navy">
|
<span class="block font-serif text-lg text-ink">
|
||||||
{doc.location ? doc.location : '—'}
|
{doc.location ? doc.location : '—'}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-sans text-xs text-gray-500">{m.doc_label_creation_location()}</span>
|
<span class="font-sans text-xs text-ink-2">{m.doc_label_creation_location()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Physical Archive Location -->
|
<!-- Physical Archive Location -->
|
||||||
{#if doc.documentLocation}
|
{#if doc.documentLocation}
|
||||||
<div class="flex items-start">
|
<div class="flex items-start">
|
||||||
<span class="mt-0.5 w-8 text-brand-mint">
|
<span class="mt-0.5 w-8 text-accent">
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -74,10 +74,10 @@ let { doc }: { doc: Doc } = $props();
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<span class="block font-serif text-lg text-brand-navy">
|
<span class="block font-serif text-lg text-ink">
|
||||||
{doc.documentLocation}
|
{doc.documentLocation}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-sans text-xs text-gray-500"
|
<span class="font-sans text-xs text-ink-2"
|
||||||
>{m.doc_label_archive_location_original()}</span
|
>{m.doc_label_archive_location_original()}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +87,7 @@ let { doc }: { doc: Doc } = $props();
|
|||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
{#if doc.tags && doc.tags.length > 0}
|
{#if doc.tags && doc.tags.length > 0}
|
||||||
<div class="flex items-start">
|
<div class="flex items-start">
|
||||||
<span class="mt-0.5 w-8 text-brand-mint">
|
<span class="mt-0.5 w-8 text-accent">
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -100,14 +100,14 @@ let { doc }: { doc: Doc } = $props();
|
|||||||
{#each doc.tags as tag (tag.id)}
|
{#each doc.tags as tag (tag.id)}
|
||||||
<a
|
<a
|
||||||
href="/?tag={encodeURIComponent(tag.name)}"
|
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"
|
class="inline-flex items-center rounded bg-muted px-2 py-0.5 text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-white"
|
||||||
title={m.doc_tag_filter_title({ name: tag.name })}
|
title={m.doc_tag_filter_title({ name: tag.name })}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<span class="font-sans text-xs text-gray-500">{m.form_label_tags()}</span>
|
<span class="font-sans text-xs text-ink-2">{m.form_label_tags()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -117,61 +117,59 @@ let { doc }: { doc: Doc } = $props();
|
|||||||
<!-- PERSONEN GROUP -->
|
<!-- PERSONEN GROUP -->
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||||
>
|
>
|
||||||
{m.doc_section_persons()}
|
{m.doc_section_persons()}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
<span class="mb-2 block font-sans text-xs text-ink-3 uppercase">{m.form_label_sender()}</span>
|
||||||
>{m.form_label_sender()}</span
|
|
||||||
>
|
|
||||||
{#if doc.sender}
|
{#if doc.sender}
|
||||||
<a
|
<a
|
||||||
href="/persons/{doc.sender.id}"
|
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"
|
class="group block rounded border border-line bg-muted p-3 transition hover:border-accent hover:bg-accent/10"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-serif text-sm text-white"
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-serif text-sm text-white"
|
||||||
>
|
>
|
||||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
class="font-serif text-brand-navy decoration-brand-mint underline-offset-2 group-hover:underline"
|
class="font-serif text-ink decoration-brand-mint underline-offset-2 group-hover:underline"
|
||||||
>
|
>
|
||||||
{doc.sender.firstName}
|
{doc.sender.firstName}
|
||||||
{doc.sender.lastName}
|
{doc.sender.lastName}
|
||||||
</p>
|
</p>
|
||||||
{#if doc.sender.alias}
|
{#if doc.sender.alias}
|
||||||
<p class="font-sans text-xs text-gray-500">{doc.sender.alias}</p>
|
<p class="font-sans text-xs text-ink-2">{doc.sender.alias}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="font-serif text-sm text-gray-400 italic">{m.doc_sender_not_specified()}</span>
|
<span class="font-serif text-sm text-ink-3 italic">{m.doc_sender_not_specified()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
<span class="mb-2 block font-sans text-xs text-ink-3 uppercase"
|
||||||
>{m.form_label_receivers()}</span
|
>{m.form_label_receivers()}</span
|
||||||
>
|
>
|
||||||
{#if doc.receivers && doc.receivers.length > 0}
|
{#if doc.receivers && doc.receivers.length > 0}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each doc.receivers as receiver (receiver.id)}
|
{#each doc.receivers as receiver (receiver.id)}
|
||||||
<div
|
<div
|
||||||
class="group flex items-center justify-between rounded border border-brand-sand bg-white p-3 transition hover:border-brand-navy"
|
class="group flex items-center justify-between rounded border border-line bg-surface p-3 transition hover:border-primary"
|
||||||
>
|
>
|
||||||
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
|
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<div
|
<div
|
||||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-gray-100 font-serif text-xs text-gray-500"
|
class="flex h-6 w-6 items-center justify-center rounded-full bg-muted font-serif text-xs text-ink-2"
|
||||||
>
|
>
|
||||||
{receiver.firstName[0]}{receiver.lastName[0]}
|
{receiver.firstName[0]}{receiver.lastName[0]}
|
||||||
</div>
|
</div>
|
||||||
<span class="truncate font-serif text-sm text-brand-navy">
|
<span class="truncate font-serif text-sm text-ink">
|
||||||
{receiver.firstName}
|
{receiver.firstName}
|
||||||
{receiver.lastName}
|
{receiver.lastName}
|
||||||
</span>
|
</span>
|
||||||
@@ -180,7 +178,7 @@ let { doc }: { doc: Doc } = $props();
|
|||||||
{#if doc.sender}
|
{#if doc.sender}
|
||||||
<a
|
<a
|
||||||
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
|
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
|
||||||
class="text-gray-300 transition hover:text-brand-mint"
|
class="text-ink-3 transition hover:text-accent"
|
||||||
title={m.doc_conversation_title()}
|
title={m.doc_conversation_title()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -195,7 +193,7 @@ let { doc }: { doc: Doc } = $props();
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="font-serif text-sm text-gray-400 italic">{m.doc_no_receivers()}</span>
|
<span class="font-serif text-sm text-ink-3 italic">{m.doc_no_receivers()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,28 +12,24 @@ let { doc }: { doc: Doc } = $props();
|
|||||||
<div class="flex justify-center px-6 py-8">
|
<div class="flex justify-center px-6 py-8">
|
||||||
<div class="w-full max-w-prose space-y-8">
|
<div class="w-full max-w-prose space-y-8">
|
||||||
{#if !doc.summary && !doc.transcription}
|
{#if !doc.summary && !doc.transcription}
|
||||||
<p class="font-serif text-sm text-gray-400 italic">—</p>
|
<p class="font-serif text-sm text-ink-3 italic">—</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if doc.summary}
|
{#if doc.summary}
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
class="mb-3 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
|
||||||
>
|
|
||||||
{m.doc_label_summary()}
|
{m.doc_label_summary()}
|
||||||
</span>
|
</span>
|
||||||
<p class="font-serif text-base leading-relaxed text-brand-navy">{doc.summary}</p>
|
<p class="font-serif text-base leading-relaxed text-ink">{doc.summary}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if doc.transcription}
|
{#if doc.transcription}
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
class="mb-3 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
|
||||||
>
|
|
||||||
{m.form_label_transcription()}
|
{m.form_label_transcription()}
|
||||||
</span>
|
</span>
|
||||||
<p class="font-serif text-base leading-relaxed whitespace-pre-wrap text-brand-navy">
|
<p class="font-serif text-base leading-relaxed whitespace-pre-wrap text-ink">
|
||||||
{doc.transcription}
|
{doc.transcription}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,19 +3,24 @@ 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 { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
url,
|
url,
|
||||||
documentId = '',
|
documentId = '',
|
||||||
annotateMode = $bindable(false),
|
annotateMode = $bindable(false),
|
||||||
activeAnnotationId = $bindable<string | null>(null),
|
activeAnnotationId = $bindable<string | null>(null),
|
||||||
onAnnotationClick
|
activeAnnotationPage = $bindable<number | null>(null),
|
||||||
|
onAnnotationClick,
|
||||||
|
documentFileHash
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
annotateMode?: boolean;
|
annotateMode?: boolean;
|
||||||
activeAnnotationId?: string | null;
|
activeAnnotationId?: string | null;
|
||||||
|
activeAnnotationPage?: number | null;
|
||||||
onAnnotationClick?: (id: string) => void;
|
onAnnotationClick?: (id: string) => void;
|
||||||
|
documentFileHash?: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||||
@@ -48,11 +53,18 @@ 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 annotateColor = $state('#ffff00');
|
let annotateColor = $state('#ffff00');
|
||||||
let commentCounts = new SvelteMap<string, number>();
|
let commentCounts = new SvelteMap<string, number>();
|
||||||
|
let showAnnotations = $state(true);
|
||||||
|
|
||||||
|
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
|
||||||
@@ -213,6 +225,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;
|
||||||
|
activeAnnotationPage = created.pageNumber;
|
||||||
onAnnotationClick?.(created.id);
|
onAnnotationClick?.(created.id);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -236,6 +249,8 @@ async function handleAnnotationDelete(annotationId: string) {
|
|||||||
|
|
||||||
function handleAnnotationClick(id: string) {
|
function handleAnnotationClick(id: string) {
|
||||||
activeAnnotationId = id;
|
activeAnnotationId = id;
|
||||||
|
const ann = annotations.find((a) => a.id === id);
|
||||||
|
activeAnnotationPage = ann?.pageNumber ?? null;
|
||||||
onAnnotationClick?.(id);
|
onAnnotationClick?.(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +277,10 @@ $effect(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (annotateMode) showAnnotations = true;
|
||||||
|
});
|
||||||
|
|
||||||
function prevPage() {
|
function prevPage() {
|
||||||
if (currentPage > 1) currentPage -= 1;
|
if (currentPage > 1) currentPage -= 1;
|
||||||
}
|
}
|
||||||
@@ -280,28 +299,47 @@ function zoomOut() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !url}
|
{#if !url}
|
||||||
<div class="flex h-full w-full items-center justify-center bg-[#2A2A2A] text-gray-400">
|
<div class="flex h-full w-full items-center justify-center bg-pdf-bg text-ink-3">
|
||||||
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div
|
<div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3">
|
||||||
class="flex h-full w-full flex-col items-center justify-center gap-3 bg-[#2A2A2A] text-gray-300"
|
|
||||||
>
|
|
||||||
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="font-sans text-xs text-brand-mint underline hover:opacity-80"
|
class="font-sans text-xs text-accent underline hover:opacity-80"
|
||||||
>
|
>
|
||||||
Direkt öffnen
|
Direkt öffnen
|
||||||
</a>
|
</a>
|
||||||
</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-pdf-bg">
|
||||||
|
{#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-pdf-ctrl px-4 py-2"
|
||||||
>
|
>
|
||||||
<!-- Page navigation -->
|
<!-- Page navigation -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -309,7 +347,7 @@ function zoomOut() {
|
|||||||
onclick={prevPage}
|
onclick={prevPage}
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
aria-label="Zurück"
|
aria-label="Zurück"
|
||||||
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
|
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -323,7 +361,7 @@ function zoomOut() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if totalPages > 0}
|
{#if totalPages > 0}
|
||||||
<span class="font-sans text-xs text-gray-300 tabular-nums">
|
<span class="font-sans text-xs text-ink-3 tabular-nums">
|
||||||
{currentPage} / {totalPages}
|
{currentPage} / {totalPages}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -332,7 +370,7 @@ function zoomOut() {
|
|||||||
onclick={nextPage}
|
onclick={nextPage}
|
||||||
disabled={!pdfDoc || currentPage >= totalPages}
|
disabled={!pdfDoc || currentPage >= totalPages}
|
||||||
aria-label="Weiter"
|
aria-label="Weiter"
|
||||||
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
|
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -351,7 +389,7 @@ function zoomOut() {
|
|||||||
<button
|
<button
|
||||||
onclick={zoomOut}
|
onclick={zoomOut}
|
||||||
aria-label="Verkleinern"
|
aria-label="Verkleinern"
|
||||||
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
|
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -369,7 +407,7 @@ function zoomOut() {
|
|||||||
<button
|
<button
|
||||||
onclick={zoomIn}
|
onclick={zoomIn}
|
||||||
aria-label="Vergrößern"
|
aria-label="Vergrößern"
|
||||||
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
|
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -396,6 +434,44 @@ function zoomOut() {
|
|||||||
title="Farbe wählen"
|
title="Farbe wählen"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- Annotation visibility toggle (shown when annotations exist) -->
|
||||||
|
{#if annotations.length > 0}
|
||||||
|
<button
|
||||||
|
onclick={() => (showAnnotations = !showAnnotations)}
|
||||||
|
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||||
|
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
||||||
|
? 'text-ink-3 hover:bg-surface/10'
|
||||||
|
: 'bg-surface/10 text-accent'}"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5 shrink-0"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
{#if showAnnotations}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
{showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PDF canvas area -->
|
<!-- PDF canvas area -->
|
||||||
@@ -419,15 +495,17 @@ function zoomOut() {
|
|||||||
class="textLayer"
|
class="textLayer"
|
||||||
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
|
{#if showAnnotations}
|
||||||
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
|
<AnnotationLayer
|
||||||
canAnnotate={annotateMode}
|
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
|
||||||
color={annotateColor}
|
canAnnotate={annotateMode}
|
||||||
onDraw={handleAnnotationDraw}
|
color={annotateColor}
|
||||||
onDelete={handleAnnotationDelete}
|
onDraw={handleAnnotationDraw}
|
||||||
commentCounts={Object.fromEntries(commentCounts)}
|
onDelete={handleAnnotationDelete}
|
||||||
onAnnotationClick={handleAnnotationClick}
|
commentCounts={Object.fromEntries(commentCounts)}
|
||||||
/>
|
onAnnotationClick={handleAnnotationClick}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -80,18 +80,18 @@ function clickOutside(node: HTMLElement) {
|
|||||||
|
|
||||||
<div class="relative" use:clickOutside>
|
<div class="relative" use:clickOutside>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy"
|
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||||
>
|
>
|
||||||
{#each selectedPersons as person (person.id)}
|
{#each selectedPersons as person (person.id)}
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1 rounded bg-brand-sand/40 px-2 py-1 text-sm font-medium text-brand-navy"
|
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
|
||||||
>
|
>
|
||||||
{person.firstName}
|
{person.firstName}
|
||||||
{person.lastName}
|
{person.lastName}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => removePerson(person.id)}
|
onclick={() => removePerson(person.id)}
|
||||||
class="ml-0.5 text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
|
||||||
aria-label={m.comp_multiselect_remove()}
|
aria-label={m.comp_multiselect_remove()}
|
||||||
>
|
>
|
||||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -121,14 +121,14 @@ function clickOutside(node: HTMLElement) {
|
|||||||
{#if showDropdown && (results.length > 0 || loading)}
|
{#if showDropdown && (results.length > 0 || loading)}
|
||||||
<div
|
<div
|
||||||
style={dropdownStyle}
|
style={dropdownStyle}
|
||||||
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
|
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
|
||||||
>
|
>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="p-2 text-sm text-gray-500">{m.comp_multiselect_loading()}</div>
|
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each results as person (person.id)}
|
{#each results as person (person.id)}
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-brand-sand/30"
|
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
|
||||||
onclick={() => selectPerson(person)}
|
onclick={() => selectPerson(person)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ function clickOutside(node: HTMLElement) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative" use:clickOutside>
|
<div class="relative" use:clickOutside>
|
||||||
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
|
<label for={name} class="block text-sm font-medium text-ink-2">{label}</label>
|
||||||
|
|
||||||
<input type="hidden" name={name} bind:value={value} />
|
<input type="hidden" name={name} bind:value={value} />
|
||||||
|
|
||||||
@@ -123,19 +123,19 @@ function clickOutside(node: HTMLElement) {
|
|||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onfocus={handleFocus}
|
onfocus={handleFocus}
|
||||||
placeholder={m.comp_typeahead_placeholder()}
|
placeholder={m.comp_typeahead_placeholder()}
|
||||||
class="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
class="mt-1 block w-full rounded-md border border-line p-2 shadow-sm focus:border-accent focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if showDropdown && (results.length > 0 || loading)}
|
{#if showDropdown && (results.length > 0 || loading)}
|
||||||
<div
|
<div
|
||||||
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||||
>
|
>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="p-2 text-sm text-gray-500">{m.comp_typeahead_loading()}</div>
|
<div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each results as person (person.id)}
|
{#each results as person (person.id)}
|
||||||
<div
|
<div
|
||||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-blue-100"
|
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
|
||||||
onclick={() => selectPerson(person)}
|
onclick={() => selectPerson(person)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@@ -85,19 +85,17 @@ function clickOutside(node: HTMLElement) {
|
|||||||
<div class="w-full" use:clickOutside>
|
<div class="w-full" use:clickOutside>
|
||||||
<!-- Tag Container -->
|
<!-- Tag Container -->
|
||||||
<div
|
<div
|
||||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy"
|
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||||
>
|
>
|
||||||
<!-- Render Selected Tags -->
|
<!-- Render Selected Tags -->
|
||||||
{#each tags as tag, i (i)}
|
{#each tags as tag, i (i)}
|
||||||
<span
|
<span class="flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
|
||||||
class="flex items-center gap-1 rounded bg-brand-sand/30 px-2 py-1 text-sm font-medium text-brand-navy"
|
|
||||||
>
|
|
||||||
{tag}
|
{tag}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => removeTag(i)}
|
onclick={() => removeTag(i)}
|
||||||
aria-label={m.comp_taginput_remove()}
|
aria-label={m.comp_taginput_remove()}
|
||||||
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
class="text-ink/50 hover:text-red-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
@@ -130,16 +128,16 @@ function clickOutside(node: HTMLElement) {
|
|||||||
<!-- Typeahead Dropdown -->
|
<!-- Typeahead Dropdown -->
|
||||||
{#if showSuggestions && suggestions.length > 0}
|
{#if showSuggestions && suggestions.length > 0}
|
||||||
<ul
|
<ul
|
||||||
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-gray-200 bg-white shadow-lg"
|
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-line bg-surface shadow-lg"
|
||||||
>
|
>
|
||||||
{#each suggestions as suggestion, i (i)}
|
{#each suggestions as suggestion, i (i)}
|
||||||
<li
|
<li
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === activeIndex}
|
aria-selected={i === activeIndex}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-brand-sand/20 {i === activeIndex
|
class="cursor-pointer px-3 py-2 text-sm hover:bg-muted {i === activeIndex
|
||||||
? 'bg-brand-sand/20 font-bold text-brand-navy'
|
? 'bg-muted font-bold text-ink'
|
||||||
: 'text-gray-700'}"
|
: 'text-ink-2'}"
|
||||||
onclick={() => addTag(suggestion)}
|
onclick={() => addTag(suggestion)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
||||||
>
|
>
|
||||||
@@ -151,6 +149,6 @@ function clickOutside(node: HTMLElement) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if allowCreation}
|
{#if allowCreation}
|
||||||
<p class="mt-1 text-xs text-gray-400">{m.comp_taginput_create_hint()}</p>
|
<p class="mt-1 text-xs text-ink-3">{m.comp_taginput_create_hint()}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
69
frontend/src/lib/components/ThemeToggle.svelte
Normal file
69
frontend/src/lib/components/ThemeToggle.svelte
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
function systemPrefersDark(): boolean {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInitialTheme(): Theme {
|
||||||
|
const saved = localStorage.getItem('theme');
|
||||||
|
if (saved === 'light' || saved === 'dark') return saved;
|
||||||
|
return systemPrefersDark() ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
let theme = $state<Theme>('light');
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
theme = resolveInitialTheme();
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
theme = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggle}
|
||||||
|
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||||
|
title={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||||
|
class="rounded p-1.5 text-ink-2 transition-colors hover:bg-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
{#if theme === 'dark'}
|
||||||
|
<!-- Sun icon — click to go light -->
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- Moon icon — click to go dark -->
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
@@ -180,6 +180,86 @@ 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;
|
||||||
@@ -260,6 +340,22 @@ 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;
|
||||||
@@ -420,6 +516,22 @@ 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 {
|
||||||
@@ -510,6 +622,7 @@ 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";
|
||||||
@@ -548,6 +661,63 @@ 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;
|
||||||
@@ -1062,6 +1232,205 @@ 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;
|
||||||
@@ -1192,6 +1561,54 @@ 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?: {
|
||||||
@@ -1422,4 +1839,25 @@ 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { page } from '$app/state';
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||||
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
|
|
||||||
@@ -23,6 +24,10 @@ onMount(() => {
|
|||||||
hydrated = true;
|
hydrated = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isAuthPage = $derived(
|
||||||
|
['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p))
|
||||||
|
);
|
||||||
|
|
||||||
let userMenuOpen = $state(false);
|
let userMenuOpen = $state(false);
|
||||||
|
|
||||||
const userInitials = $derived.by(() => {
|
const userInitials = $derived.by(() => {
|
||||||
@@ -45,9 +50,9 @@ function clickOutside(node: HTMLElement) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-white" data-hydrated={hydrated || undefined}>
|
<div class="min-h-screen bg-canvas" data-hydrated={hydrated || undefined}>
|
||||||
{#if !['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p))}
|
{#if !isAuthPage}
|
||||||
<header class="sticky top-0 z-50 border-b border-gray-100 bg-white">
|
<header class="sticky top-0 z-50 border-b border-line-2 bg-surface">
|
||||||
<!-- De Gruyter Brill purple accent strip -->
|
<!-- De Gruyter Brill purple accent strip -->
|
||||||
<div class="h-1 bg-brand-purple"></div>
|
<div class="h-1 bg-brand-purple"></div>
|
||||||
|
|
||||||
@@ -57,7 +62,7 @@ function clickOutside(node: HTMLElement) {
|
|||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="mr-10 flex flex-shrink-0 items-center">
|
<div class="mr-10 flex flex-shrink-0 items-center">
|
||||||
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
||||||
<span class="font-sans text-xl font-bold tracking-widest text-brand-navy uppercase"
|
<span class="font-sans text-xl font-bold tracking-widest text-ink uppercase"
|
||||||
>Familienarchiv</span
|
>Familienarchiv</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
@@ -68,8 +73,8 @@ function clickOutside(node: HTMLElement) {
|
|||||||
href="/"
|
href="/"
|
||||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
? 'rounded bg-nav-active text-ink'
|
||||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||||
>
|
>
|
||||||
{m.nav_documents()}
|
{m.nav_documents()}
|
||||||
</a>
|
</a>
|
||||||
@@ -78,8 +83,8 @@ function clickOutside(node: HTMLElement) {
|
|||||||
href="/persons"
|
href="/persons"
|
||||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||||
{page.url.pathname.startsWith('/persons')
|
{page.url.pathname.startsWith('/persons')
|
||||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
? 'rounded bg-nav-active text-ink'
|
||||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||||
>
|
>
|
||||||
{m.nav_persons()}
|
{m.nav_persons()}
|
||||||
</a>
|
</a>
|
||||||
@@ -88,8 +93,8 @@ function clickOutside(node: HTMLElement) {
|
|||||||
href="/conversations"
|
href="/conversations"
|
||||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||||
{page.url.pathname.startsWith('/conversations')
|
{page.url.pathname.startsWith('/conversations')
|
||||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
? 'rounded bg-nav-active text-ink'
|
||||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||||
>
|
>
|
||||||
{m.nav_conversations()}
|
{m.nav_conversations()}
|
||||||
</a>
|
</a>
|
||||||
@@ -98,8 +103,8 @@ function clickOutside(node: HTMLElement) {
|
|||||||
href="/admin"
|
href="/admin"
|
||||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||||
{page.url.pathname.startsWith('/admin')
|
{page.url.pathname.startsWith('/admin')
|
||||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
? 'rounded bg-nav-active text-ink'
|
||||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||||
>
|
>
|
||||||
{m.nav_admin()}
|
{m.nav_admin()}
|
||||||
</a>
|
</a>
|
||||||
@@ -110,21 +115,24 @@ function clickOutside(node: HTMLElement) {
|
|||||||
<!-- Right Side -->
|
<!-- Right Side -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- Language selector -->
|
<!-- Language selector -->
|
||||||
<div class="flex items-center gap-1 border-r border-gray-200 pr-3">
|
<div class="flex items-center gap-1 border-r border-line pr-3">
|
||||||
{#each locales as locale (locale)}
|
{#each locales as locale (locale)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => setLocale(localeMap[locale])}
|
onclick={() => setLocale(localeMap[locale])}
|
||||||
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
|
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
|
||||||
{activeLocale === locale
|
{activeLocale === locale
|
||||||
? 'font-bold text-brand-navy'
|
? 'font-bold text-ink'
|
||||||
: 'font-normal text-gray-400 hover:text-brand-navy'}"
|
: 'font-normal text-ink-3 hover:text-ink'}"
|
||||||
>
|
>
|
||||||
{locale}
|
{locale}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Theme toggle -->
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
<!-- User menu -->
|
<!-- User menu -->
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
@@ -138,7 +146,7 @@ function clickOutside(node: HTMLElement) {
|
|||||||
aria-expanded={userMenuOpen}
|
aria-expanded={userMenuOpen}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-sans text-xs font-bold text-white transition-opacity hover:opacity-80"
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-sans text-xs font-bold text-white transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
{userInitials}
|
{userInitials}
|
||||||
</button>
|
</button>
|
||||||
@@ -149,7 +157,7 @@ function clickOutside(node: HTMLElement) {
|
|||||||
aria-expanded={userMenuOpen}
|
aria-expanded={userMenuOpen}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||||
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
|
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
|
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
|
||||||
@@ -162,20 +170,20 @@ function clickOutside(node: HTMLElement) {
|
|||||||
|
|
||||||
{#if userMenuOpen}
|
{#if userMenuOpen}
|
||||||
<div
|
<div
|
||||||
class="absolute top-full right-0 z-50 mt-1 min-w-[10rem] rounded-sm border border-brand-sand bg-white shadow-md"
|
class="absolute top-full right-0 z-50 mt-1 min-w-[10rem] rounded-sm border border-line bg-overlay shadow-md"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/profile"
|
href="/profile"
|
||||||
onclick={() => (userMenuOpen = false)}
|
onclick={() => (userMenuOpen = false)}
|
||||||
class="block px-4 py-2.5 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
|
class="block px-4 py-2.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted hover:text-ink"
|
||||||
>
|
>
|
||||||
{m.nav_profile()}
|
{m.nav_profile()}
|
||||||
</a>
|
</a>
|
||||||
<div class="border-t border-brand-sand">
|
<div class="border-t border-line">
|
||||||
<form action="/logout" method="POST" use:enhance>
|
<form action="/logout" method="POST" use:enhance>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="w-full px-4 py-2.5 text-left font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
|
class="w-full px-4 py-2.5 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:bg-muted hover:text-ink"
|
||||||
>
|
>
|
||||||
{m.nav_logout()}
|
{m.nav_logout()}
|
||||||
</button>
|
</button>
|
||||||
@@ -190,7 +198,7 @@ function clickOutside(node: HTMLElement) {
|
|||||||
</header>
|
</header>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<main class="py-6">
|
<main class={isAuthPage ? '' : 'py-6'}>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
import TagInput from '$lib/components/TagInput.svelte';
|
import TagInput from '$lib/components/TagInput.svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
@@ -18,6 +18,15 @@ let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
|||||||
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
||||||
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
|
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
|
||||||
|
|
||||||
|
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/tiff'];
|
||||||
|
|
||||||
|
let isDragging = $state(false);
|
||||||
|
let windowDragging = $state(false);
|
||||||
|
let dragCounter = 0;
|
||||||
|
let isUploading = $state(false);
|
||||||
|
let uploadMessages = $state<{ text: string; isError: boolean }[]>([]);
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout>;
|
let searchTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
||||||
@@ -29,6 +38,83 @@ const hasAdvancedFilters = (filters: typeof data.filters) =>
|
|||||||
|
|
||||||
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
|
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = false;
|
||||||
|
windowDragging = false;
|
||||||
|
dragCounter = 0;
|
||||||
|
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||||
|
await uploadFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelect(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const files = Array.from(input.files ?? []);
|
||||||
|
input.value = '';
|
||||||
|
await uploadFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFiles(files: File[]) {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
const messages: { text: string; isError: boolean }[] = [];
|
||||||
|
|
||||||
|
// Client-side type validation
|
||||||
|
const valid: File[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||||
|
messages.push({ text: m.upload_invalid_type({ filename: file.name }), isError: true });
|
||||||
|
} else {
|
||||||
|
valid.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valid.length === 0) {
|
||||||
|
uploadMessages = messages;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploading = true;
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of valid) {
|
||||||
|
formData.append('files', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/documents/quick-upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.created?.length > 0) {
|
||||||
|
messages.push({ text: m.upload_success({ count: result.created.length }), isError: false });
|
||||||
|
}
|
||||||
|
for (const err of result.errors ?? []) {
|
||||||
|
messages.push({ text: err, isError: true });
|
||||||
|
}
|
||||||
|
await invalidateAll();
|
||||||
|
} else {
|
||||||
|
for (const file of valid) {
|
||||||
|
messages.push({ text: m.upload_error({ filename: file.name }), isError: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isUploading = false;
|
||||||
|
uploadMessages = messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function triggerSearch() {
|
function triggerSearch() {
|
||||||
const params = new SvelteURLSearchParams();
|
const params = new SvelteURLSearchParams();
|
||||||
|
|
||||||
@@ -62,6 +148,40 @@ $effect(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Expand drop zone whenever a file is dragged anywhere over the browser window
|
||||||
|
$effect(() => {
|
||||||
|
if (!data.canWrite) return;
|
||||||
|
|
||||||
|
function onWindowDragEnter(e: DragEvent) {
|
||||||
|
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||||
|
dragCounter++;
|
||||||
|
windowDragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowDragLeave() {
|
||||||
|
dragCounter--;
|
||||||
|
if (dragCounter <= 0) {
|
||||||
|
dragCounter = 0;
|
||||||
|
windowDragging = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowDrop() {
|
||||||
|
dragCounter = 0;
|
||||||
|
windowDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('dragenter', onWindowDragEnter);
|
||||||
|
window.addEventListener('dragleave', onWindowDragLeave);
|
||||||
|
window.addEventListener('drop', onWindowDrop);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('dragenter', onWindowDragEnter);
|
||||||
|
window.removeEventListener('dragleave', onWindowDragLeave);
|
||||||
|
window.removeEventListener('drop', onWindowDrop);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Sync local state with server data after navigation.
|
// Sync local state with server data after navigation.
|
||||||
// Guard q: skip overwrite while the user is actively typing in the search field.
|
// Guard q: skip overwrite while the user is actively typing in the search field.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -78,7 +198,7 @@ $effect(() => {
|
|||||||
<!-- Outer Container: Matches the 'Sand' background of the layout -->
|
<!-- Outer Container: Matches the 'Sand' background of the layout -->
|
||||||
<main class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
<main class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
||||||
<!-- SEARCH & FILTER CARD -->
|
<!-- SEARCH & FILTER CARD -->
|
||||||
<div class="mb-8 rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="mb-8 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<!-- ROW 1: Main Search (One Line) -->
|
<!-- ROW 1: Main Search (One Line) -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<!-- Full Text Search -->
|
<!-- Full Text Search -->
|
||||||
@@ -90,7 +210,7 @@ $effect(() => {
|
|||||||
onfocus={() => (qFocused = true)}
|
onfocus={() => (qFocused = true)}
|
||||||
onblur={() => (qFocused = false)}
|
onblur={() => (qFocused = false)}
|
||||||
placeholder={m.docs_search_placeholder()}
|
placeholder={m.docs_search_placeholder()}
|
||||||
class="block w-full border-gray-300 py-2.5 pr-10 pl-3 placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:border-ink focus:ring-ink"
|
||||||
/>
|
/>
|
||||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
<img
|
<img
|
||||||
@@ -105,7 +225,7 @@ $effect(() => {
|
|||||||
<!-- Toggle Advanced Button -->
|
<!-- Toggle Advanced Button -->
|
||||||
<button
|
<button
|
||||||
onclick={() => (showAdvanced = !showAdvanced)}
|
onclick={() => (showAdvanced = !showAdvanced)}
|
||||||
class="flex items-center gap-2 border border-gray-300 bg-gray-50 px-4 py-2.5 text-sm font-bold tracking-wide text-gray-600 uppercase transition hover:bg-gray-100 hover:text-brand-navy"
|
class="flex items-center gap-2 border border-line bg-muted px-4 py-2.5 text-sm font-bold tracking-wide text-ink-2 uppercase transition hover:bg-muted hover:text-ink"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg"
|
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg"
|
||||||
@@ -119,7 +239,7 @@ $effect(() => {
|
|||||||
<!-- Reset Button -->
|
<!-- Reset Button -->
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="flex items-center justify-center border border-transparent px-3 py-2.5 text-gray-400 transition hover:text-red-500"
|
class="flex items-center justify-center border border-transparent px-3 py-2.5 text-ink-3 transition hover:text-red-500"
|
||||||
title={m.docs_btn_reset_title()}
|
title={m.docs_btn_reset_title()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -135,11 +255,11 @@ $effect(() => {
|
|||||||
{#if showAdvanced}
|
{#if showAdvanced}
|
||||||
<div
|
<div
|
||||||
transition:slide
|
transition:slide
|
||||||
class="mt-6 grid grid-cols-1 gap-6 border-t border-gray-100 pt-6 md:grid-cols-12"
|
class="mt-6 grid grid-cols-1 gap-6 border-t border-line-2 pt-6 md:grid-cols-12"
|
||||||
>
|
>
|
||||||
<!-- Tag Filter -->
|
<!-- Tag Filter -->
|
||||||
<div class="md:col-span-12">
|
<div class="md:col-span-12">
|
||||||
<p class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase">
|
<p class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||||
{m.docs_filter_label_tags()}
|
{m.docs_filter_label_tags()}
|
||||||
</p>
|
</p>
|
||||||
<TagInput bind:tags={tagNames} allowCreation={false} />
|
<TagInput bind:tags={tagNames} allowCreation={false} />
|
||||||
@@ -148,7 +268,7 @@ $effect(() => {
|
|||||||
<!-- Sender -->
|
<!-- Sender -->
|
||||||
<div class="md:col-span-3">
|
<div class="md:col-span-3">
|
||||||
<div
|
<div
|
||||||
class="[&_input]:border-gray-300 [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
|
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||||
>
|
>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="senderId"
|
name="senderId"
|
||||||
@@ -163,7 +283,7 @@ $effect(() => {
|
|||||||
<!-- Receiver -->
|
<!-- Receiver -->
|
||||||
<div class="md:col-span-3">
|
<div class="md:col-span-3">
|
||||||
<div
|
<div
|
||||||
class="[&_input]:border-gray-300 [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
|
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||||
>
|
>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="receiverId"
|
name="receiverId"
|
||||||
@@ -180,7 +300,7 @@ $effect(() => {
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="from"
|
for="from"
|
||||||
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
|
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
>{m.docs_filter_label_from()}</label
|
>{m.docs_filter_label_from()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -188,13 +308,13 @@ $effect(() => {
|
|||||||
id="from"
|
id="from"
|
||||||
bind:value={from}
|
bind:value={from}
|
||||||
onchange={triggerSearch}
|
onchange={triggerSearch}
|
||||||
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
|
class="block w-full border-line py-2.5 text-sm shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="to"
|
for="to"
|
||||||
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
|
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
>{m.docs_filter_label_to()}</label
|
>{m.docs_filter_label_to()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -202,7 +322,7 @@ $effect(() => {
|
|||||||
id="to"
|
id="to"
|
||||||
bind:value={to}
|
bind:value={to}
|
||||||
onchange={triggerSearch}
|
onchange={triggerSearch}
|
||||||
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
|
class="block w-full border-line py-2.5 text-sm shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,12 +330,51 @@ $effect(() => {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if data.canWrite}
|
||||||
|
<!-- UPLOAD DROP ZONE -->
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="mb-4 flex cursor-pointer items-center justify-center gap-3 border border-dashed px-6 text-sm transition-all duration-200 {isDragging
|
||||||
|
? 'border-primary bg-accent-bg py-10 text-primary'
|
||||||
|
: windowDragging
|
||||||
|
? 'border-primary/60 bg-accent-bg/50 py-10 text-primary/80'
|
||||||
|
: 'border-ink/20 py-3 text-ink-3 hover:border-primary hover:text-primary'}"
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondragleave={handleDragLeave}
|
||||||
|
ondrop={handleDrop}
|
||||||
|
onclick={() => fileInput.click()}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && fileInput.click()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Upload/Upload-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 shrink-0 opacity-50"
|
||||||
|
/>
|
||||||
|
<span class="font-sans font-medium">
|
||||||
|
{isUploading ? '…' : m.upload_drop_hint()}
|
||||||
|
</span>
|
||||||
|
<span class="font-sans text-xs text-ink-3">{m.upload_accepted_types()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if uploadMessages.length > 0}
|
||||||
|
<div class="mb-4 flex flex-col gap-1">
|
||||||
|
{#each uploadMessages as msg, i (i)}
|
||||||
|
<p class="font-sans text-sm {msg.isError ? 'text-red-600' : 'text-green-700'}">
|
||||||
|
{msg.text}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- DOCUMENT LIST HEADER -->
|
<!-- DOCUMENT LIST HEADER -->
|
||||||
<div class="mb-2 flex justify-end">
|
<div class="mb-2 flex justify-end">
|
||||||
{#if data.canWrite}
|
{#if data.canWrite}
|
||||||
<a
|
<a
|
||||||
href="/documents/new"
|
href="/documents/new"
|
||||||
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
class="inline-flex items-center gap-1 text-sm font-medium text-ink/60 transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||||
@@ -229,15 +388,15 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DOCUMENT LIST -->
|
<!-- DOCUMENT LIST -->
|
||||||
<div class="border border-brand-sand bg-white shadow-sm">
|
<div class="border border-line bg-surface shadow-sm">
|
||||||
{#if data.error}
|
{#if data.error}
|
||||||
<div class="bg-red-50 p-8 text-center text-red-600">
|
<div class="bg-red-50 p-8 text-center text-red-600">
|
||||||
{data.error}
|
{data.error}
|
||||||
</div>
|
</div>
|
||||||
{:else if data.documents && data.documents.length > 0}
|
{:else if data.documents && data.documents.length > 0}
|
||||||
<ul class="divide-y divide-gray-100">
|
<ul class="divide-y divide-line-2">
|
||||||
{#each data.documents as doc (doc.id)}
|
{#each data.documents as doc (doc.id)}
|
||||||
<li class="group transition-colors duration-200 hover:bg-brand-sand/10">
|
<li class="group transition-colors duration-200 hover:bg-muted/50">
|
||||||
<!-- LINK TO DETAIL PAGE -->
|
<!-- LINK TO DETAIL PAGE -->
|
||||||
<a href="/documents/{doc.id}" class="block p-6">
|
<a href="/documents/{doc.id}" class="block p-6">
|
||||||
<div class="flex flex-col gap-6 sm:flex-row">
|
<div class="flex flex-col gap-6 sm:flex-row">
|
||||||
@@ -246,24 +405,14 @@ $effect(() => {
|
|||||||
<div class="mb-2 flex items-baseline justify-between">
|
<div class="mb-2 flex items-baseline justify-between">
|
||||||
<!-- Title: Serif & Brand Navy -->
|
<!-- Title: Serif & Brand Navy -->
|
||||||
<h3
|
<h3
|
||||||
class="font-serif text-xl font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-4 group-hover:underline"
|
class="font-serif text-xl font-medium text-ink decoration-brand-mint decoration-2 underline-offset-4 group-hover:underline"
|
||||||
>
|
>
|
||||||
{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 -->
|
||||||
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-gray-500">
|
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-ink-2">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
||||||
@@ -290,28 +439,26 @@ $effect(() => {
|
|||||||
<div class="grid grid-cols-1 gap-4 font-serif text-sm sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 font-serif text-sm sm:grid-cols-2">
|
||||||
<div class="flex items-baseline">
|
<div class="flex items-baseline">
|
||||||
<span
|
<span
|
||||||
class="w-10 font-sans text-xs font-bold tracking-wide text-gray-400 uppercase"
|
class="w-10 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
|
||||||
>{m.docs_list_from()}</span
|
>{m.docs_list_from()}</span
|
||||||
>
|
>
|
||||||
{#if doc.sender}
|
{#if doc.sender}
|
||||||
<span class="text-gray-900"
|
<span class="text-ink">{doc.sender.firstName} {doc.sender.lastName}</span>
|
||||||
>{doc.sender.firstName} {doc.sender.lastName}</span
|
|
||||||
>
|
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-gray-400 italic">{m.docs_list_unknown()}</span>
|
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-baseline">
|
<div class="flex items-baseline">
|
||||||
<span
|
<span
|
||||||
class="w-10 font-sans text-xs font-bold tracking-wide text-gray-400 uppercase"
|
class="w-10 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
|
||||||
>{m.docs_list_to()}</span
|
>{m.docs_list_to()}</span
|
||||||
>
|
>
|
||||||
{#if doc.receivers && doc.receivers.length > 0}
|
{#if doc.receivers && doc.receivers.length > 0}
|
||||||
<span class="text-gray-900">
|
<span class="text-ink">
|
||||||
{doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
|
{doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-gray-400 italic">{m.docs_list_unknown()}</span>
|
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,7 +469,7 @@ $effect(() => {
|
|||||||
{#each doc.tags as tag (tag.id)}
|
{#each doc.tags as tag (tag.id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative z-10 inline-flex cursor-pointer items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
class="relative z-10 inline-flex cursor-pointer items-center rounded bg-muted px-2 py-1 text-[10px] font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-white"
|
||||||
onclick={(e) => { e.preventDefault(); e.stopPropagation(); goto(`/?tag=${encodeURIComponent(tag.name)}`); }}
|
onclick={(e) => { e.preventDefault(); e.stopPropagation(); goto(`/?tag=${encodeURIComponent(tag.name)}`); }}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
@@ -334,7 +481,7 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- Arrow Icon -->
|
<!-- Arrow Icon -->
|
||||||
<div
|
<div
|
||||||
class="hidden items-center text-gray-300 transition-colors group-hover:text-brand-mint sm:flex"
|
class="hidden items-center text-ink-3 transition-colors group-hover:text-accent sm:flex"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
|
||||||
@@ -351,9 +498,7 @@ $effect(() => {
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div class="p-16 text-center">
|
<div class="p-16 text-center">
|
||||||
<div
|
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-sand/30"
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -361,17 +506,25 @@ $effect(() => {
|
|||||||
class="h-6 w-6"
|
class="h-6 w-6"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="font-serif text-lg font-medium text-brand-navy">{m.docs_empty_heading()}</h3>
|
<h3 class="font-serif text-lg font-medium text-ink">{m.docs_empty_heading()}</h3>
|
||||||
<p class="mt-1 font-sans text-sm text-gray-500">
|
<p class="mt-1 font-sans text-sm text-ink-2">
|
||||||
{m.docs_empty_text()}
|
{m.docs_empty_text()}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onclick={() => goto('/')}
|
onclick={() => goto('/')}
|
||||||
class="mt-6 text-sm font-bold tracking-wide text-brand-mint uppercase transition hover:text-brand-navy"
|
class="mt-6 text-sm font-bold tracking-wide text-accent uppercase transition hover:text-ink"
|
||||||
>
|
>
|
||||||
{m.docs_empty_btn_clear()}
|
{m.docs_empty_btn_clear()}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff"
|
||||||
|
class="sr-only"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ 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'];
|
||||||
|
|
||||||
@@ -45,58 +47,72 @@ 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">
|
||||||
<div class="mb-8 flex items-center justify-between">
|
<div class="mb-8 flex items-center justify-between">
|
||||||
<h1 class="font-serif text-3xl text-brand-navy">{m.admin_heading()}</h1>
|
<h1 class="font-serif text-3xl text-ink">{m.admin_heading()}</h1>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="flex rounded-lg border border-gray-200 bg-white p-1 shadow-sm">
|
<div class="flex rounded-lg border border-line bg-surface p-1 shadow-sm">
|
||||||
<button
|
<button
|
||||||
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
||||||
'users'
|
'users'
|
||||||
? 'bg-brand-navy text-white'
|
? 'bg-primary text-white'
|
||||||
: 'text-gray-500 hover:text-brand-navy'}"
|
: 'text-ink-2 hover:text-ink'}"
|
||||||
onclick={() => (activeTab = 'users')}>{m.admin_tab_users()}</button
|
onclick={() => (activeTab = 'users')}>{m.admin_tab_users()}</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
||||||
'groups'
|
'groups'
|
||||||
? 'bg-brand-navy text-white'
|
? 'bg-primary text-white'
|
||||||
: 'text-gray-500 hover:text-brand-navy'}"
|
: 'text-ink-2 hover:text-ink'}"
|
||||||
onclick={() => (activeTab = 'groups')}>{m.admin_tab_groups()}</button
|
onclick={() => (activeTab = 'groups')}>{m.admin_tab_groups()}</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
||||||
'tags'
|
'tags'
|
||||||
? 'bg-brand-navy text-white'
|
? 'bg-primary text-white'
|
||||||
: 'text-gray-500 hover:text-brand-navy'}"
|
: 'text-ink-2 hover:text-ink'}"
|
||||||
onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button
|
onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
||||||
'system'
|
'system'
|
||||||
? 'bg-brand-navy text-white'
|
? 'bg-primary text-white'
|
||||||
: 'text-gray-500 hover:text-brand-navy'}"
|
: 'text-ink-2 hover:text-ink'}"
|
||||||
onclick={() => (activeTab = 'system')}>{m.admin_tab_system()}</button
|
onclick={() => (activeTab = 'system')}>{m.admin_tab_system()}</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if form?.message}
|
{#if form?.message}
|
||||||
<div class="mb-6 rounded border border-brand-mint/50 bg-brand-mint/20 p-4 text-brand-navy">
|
<div class="mb-6 rounded border border-accent/50 bg-accent/20 p-4 text-ink">
|
||||||
{form.message}
|
{form.message}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if activeTab === 'users'}
|
{#if activeTab === 'users'}
|
||||||
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
|
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm" in:slide>
|
||||||
<div class="flex items-center justify-between border-b border-gray-100 p-6">
|
<div class="flex items-center justify-between border-b border-line-2 p-6">
|
||||||
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_users()}</h2>
|
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_users()}</h2>
|
||||||
<a
|
<a
|
||||||
href="/admin/users/new"
|
href="/admin/users/new"
|
||||||
class="inline-flex items-center gap-1 rounded-sm bg-brand-navy px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
class="inline-flex items-center gap-1 rounded-sm bg-primary px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
@@ -110,38 +126,37 @@ async function backfillVersions() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-line">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||||
>{m.admin_col_login()}</th
|
>{m.admin_col_login()}</th
|
||||||
>
|
>
|
||||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||||
>{m.admin_col_full_name()}</th
|
>{m.admin_col_full_name()}</th
|
||||||
>
|
>
|
||||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||||
>{m.admin_col_groups()}</th
|
>{m.admin_col_groups()}</th
|
||||||
>
|
>
|
||||||
<th
|
<th class="px-6 py-3 text-right text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||||
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
|
|
||||||
>{m.admin_col_actions()}</th
|
>{m.admin_col_actions()}</th
|
||||||
>
|
>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
<tbody class="divide-y divide-line bg-surface">
|
||||||
{#each data.users as user (user.id)}
|
{#each data.users as user (user.id)}
|
||||||
<tr class="group/row hover:bg-gray-50">
|
<tr class="group/row hover:bg-muted">
|
||||||
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-gray-900">
|
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-ink">
|
||||||
{user.username}
|
{user.username}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-ink-2">
|
||||||
{#if user.firstName || user.lastName}
|
{#if user.firstName || user.lastName}
|
||||||
{user.firstName ?? ''} {user.lastName ?? ''}
|
{user.firstName ?? ''} {user.lastName ?? ''}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-gray-300 italic">–</span>
|
<span class="text-ink-3 italic">–</span>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm text-gray-500">
|
<td class="px-6 py-4 text-sm text-ink-2">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#if user.groups && user.groups.length > 0}
|
{#if user.groups && user.groups.length > 0}
|
||||||
{#each user.groups as group (group.id)}
|
{#each user.groups as group (group.id)}
|
||||||
@@ -152,7 +167,7 @@ async function backfillVersions() {
|
|||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-xs text-gray-400 italic">{m.admin_no_groups()}</span>
|
<span class="text-xs text-ink-3 italic">{m.admin_no_groups()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -160,7 +175,7 @@ async function backfillVersions() {
|
|||||||
<div class="flex items-center justify-end gap-4">
|
<div class="flex items-center justify-end gap-4">
|
||||||
<a
|
<a
|
||||||
href="/admin/users/{user.id}"
|
href="/admin/users/{user.id}"
|
||||||
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
|
class="text-sm font-bold tracking-wide text-accent uppercase hover:text-ink"
|
||||||
>
|
>
|
||||||
{m.btn_edit()}
|
{m.btn_edit()}
|
||||||
</a>
|
</a>
|
||||||
@@ -180,7 +195,7 @@ async function backfillVersions() {
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={user.id} />
|
<input type="hidden" name="id" value={user.id} />
|
||||||
<button
|
<button
|
||||||
class="p-1 text-gray-300 transition-colors hover:text-red-600"
|
class="p-1 text-ink-3 transition-colors hover:text-red-600"
|
||||||
title={m.admin_btn_delete_user_title()}
|
title={m.admin_btn_delete_user_title()}
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -201,17 +216,17 @@ async function backfillVersions() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{:else if activeTab === 'tags'}
|
{:else if activeTab === 'tags'}
|
||||||
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
|
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm" in:slide>
|
||||||
<div class="border-b border-gray-100 bg-yellow-50/50 p-6">
|
<div class="border-b border-line-2 bg-yellow-50/50 p-6">
|
||||||
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_tags()}</h2>
|
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_tags()}</h2>
|
||||||
<p class="mt-1 text-xs text-yellow-800">
|
<p class="mt-1 text-xs text-yellow-800">
|
||||||
{m.admin_tags_warning()}
|
{m.admin_tags_warning()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="max-h-[600px] divide-y divide-gray-100 overflow-y-auto">
|
<ul class="max-h-[600px] divide-y divide-line-2 overflow-y-auto">
|
||||||
{#each data.tags as tag (tag.id)}
|
{#each data.tags as tag (tag.id)}
|
||||||
<li class="group flex items-center justify-between px-6 py-3 hover:bg-gray-50">
|
<li class="group flex items-center justify-between px-6 py-3 hover:bg-muted">
|
||||||
{#if editingTagId === tag.id}
|
{#if editingTagId === tag.id}
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
@@ -228,7 +243,7 @@ async function backfillVersions() {
|
|||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
bind:value={editingTagName}
|
bind:value={editingTagName}
|
||||||
class="flex-1 rounded border-brand-mint px-2 py-1 text-sm ring-1 ring-brand-mint"
|
class="flex-1 rounded border-accent px-2 py-1 text-sm ring-1 ring-accent"
|
||||||
/>
|
/>
|
||||||
<button aria-label={m.btn_save()} class="text-green-600 hover:text-green-800"
|
<button aria-label={m.btn_save()} class="text-green-600 hover:text-green-800"
|
||||||
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
@@ -244,7 +259,7 @@ async function backfillVersions() {
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={cancelEditTag}
|
onclick={cancelEditTag}
|
||||||
aria-label={m.btn_cancel()}
|
aria-label={m.btn_cancel()}
|
||||||
class="text-gray-400 hover:text-gray-600"
|
class="text-ink-3 hover:text-ink-2"
|
||||||
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -256,7 +271,7 @@ async function backfillVersions() {
|
|||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="rounded bg-brand-sand/30 px-2 py-1 text-sm font-medium text-brand-navy">
|
<span class="rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@@ -265,7 +280,7 @@ async function backfillVersions() {
|
|||||||
<button
|
<button
|
||||||
onclick={() => startEditTag(tag)}
|
onclick={() => startEditTag(tag)}
|
||||||
aria-label={m.admin_btn_edit_tag_label()}
|
aria-label={m.admin_btn_edit_tag_label()}
|
||||||
class="p-1 text-gray-400 hover:text-brand-navy"
|
class="p-1 text-ink-3 hover:text-ink"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
@@ -294,7 +309,7 @@ async function backfillVersions() {
|
|||||||
<input type="hidden" name="id" value={tag.id} />
|
<input type="hidden" name="id" value={tag.id} />
|
||||||
<button
|
<button
|
||||||
aria-label={m.admin_btn_delete_tag_label()}
|
aria-label={m.admin_btn_delete_tag_label()}
|
||||||
class="p-1 text-gray-400 hover:text-red-600"
|
class="p-1 text-ink-3 hover:text-red-600"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
@@ -313,29 +328,28 @@ async function backfillVersions() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{:else if activeTab === 'groups'}
|
{:else if activeTab === 'groups'}
|
||||||
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
|
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm" in:slide>
|
||||||
<div class="flex items-center justify-between border-b border-gray-100 p-6">
|
<div class="flex items-center justify-between border-b border-line-2 p-6">
|
||||||
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_groups()}</h2>
|
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_groups()}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-line">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||||
>{m.admin_col_name()}</th
|
>{m.admin_col_name()}</th
|
||||||
>
|
>
|
||||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||||
>{m.admin_col_permissions()}</th
|
>{m.admin_col_permissions()}</th
|
||||||
>
|
>
|
||||||
<th
|
<th class="px-6 py-3 text-right text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||||
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
|
|
||||||
>{m.admin_col_actions()}</th
|
>{m.admin_col_actions()}</th
|
||||||
>
|
>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
<tbody class="divide-y divide-line bg-surface">
|
||||||
{#each data.groups as group (group.id)}
|
{#each data.groups as group (group.id)}
|
||||||
<tr class="group/row hover:bg-gray-50">
|
<tr class="group/row hover:bg-muted">
|
||||||
{#if editingGroupId === group.id}
|
{#if editingGroupId === group.id}
|
||||||
<!-- EDIT MODE -->
|
<!-- EDIT MODE -->
|
||||||
<td colspan="3" class="px-6 py-4">
|
<td colspan="3" class="px-6 py-4">
|
||||||
@@ -356,7 +370,7 @@ async function backfillVersions() {
|
|||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
value={group.name}
|
value={group.name}
|
||||||
class="w-full rounded border-brand-mint text-sm"
|
class="w-full rounded border-accent text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,14 +378,14 @@ async function backfillVersions() {
|
|||||||
<div class="flex h-full flex-1 flex-wrap items-center gap-4 pt-2">
|
<div class="flex h-full flex-1 flex-wrap items-center gap-4 pt-2">
|
||||||
{#each availablePermissions as perm (perm)}
|
{#each availablePermissions as perm (perm)}
|
||||||
<label
|
<label
|
||||||
class="inline-flex items-center text-xs font-bold text-gray-600 uppercase"
|
class="inline-flex items-center text-xs font-bold text-ink-2 uppercase"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="permissions"
|
name="permissions"
|
||||||
value={perm}
|
value={perm}
|
||||||
checked={group.permissions.includes(perm)}
|
checked={group.permissions.includes(perm)}
|
||||||
class="mr-2 rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
|
class="mr-2 rounded border-line text-ink focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
{perm.replace('_', ' ')}
|
{perm.replace('_', ' ')}
|
||||||
</label>
|
</label>
|
||||||
@@ -397,7 +411,7 @@ async function backfillVersions() {
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={cancelEditGroup}
|
onclick={cancelEditGroup}
|
||||||
aria-label={m.btn_cancel()}
|
aria-label={m.btn_cancel()}
|
||||||
class="p-1 text-gray-400 hover:text-red-500"
|
class="p-1 text-ink-3 hover:text-red-500"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
@@ -413,17 +427,17 @@ async function backfillVersions() {
|
|||||||
</td>
|
</td>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- VIEW MODE -->
|
<!-- VIEW MODE -->
|
||||||
<td class="px-6 py-4 text-sm font-bold whitespace-nowrap text-brand-navy">
|
<td class="px-6 py-4 text-sm font-bold whitespace-nowrap text-ink">
|
||||||
{group.name}
|
{group.name}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm text-gray-500">
|
<td class="px-6 py-4 text-sm text-ink-2">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#each group.permissions as perm (perm)}
|
{#each group.permissions as perm (perm)}
|
||||||
<span
|
<span
|
||||||
class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase
|
class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase
|
||||||
{perm === 'ADMIN'
|
{perm === 'ADMIN'
|
||||||
? 'border-red-100 bg-red-50 text-red-700'
|
? 'border-red-100 bg-red-50 text-red-700'
|
||||||
: 'border-gray-200 bg-gray-100 text-gray-600'}"
|
: 'border-line bg-muted text-ink-2'}"
|
||||||
>
|
>
|
||||||
{perm}
|
{perm}
|
||||||
</span>
|
</span>
|
||||||
@@ -434,7 +448,7 @@ async function backfillVersions() {
|
|||||||
<div class="flex items-center justify-end gap-3">
|
<div class="flex items-center justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onclick={() => startEditGroup(group.id)}
|
onclick={() => startEditGroup(group.id)}
|
||||||
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
|
class="text-sm font-bold tracking-wide text-accent uppercase hover:text-ink"
|
||||||
>
|
>
|
||||||
{m.btn_edit()}
|
{m.btn_edit()}
|
||||||
</button>
|
</button>
|
||||||
@@ -453,7 +467,7 @@ async function backfillVersions() {
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={group.id} />
|
<input type="hidden" name="id" value={group.id} />
|
||||||
<button
|
<button
|
||||||
class="p-1 text-gray-300 transition-colors hover:text-red-600"
|
class="p-1 text-ink-3 transition-colors hover:text-red-600"
|
||||||
title={m.btn_delete()}
|
title={m.btn_delete()}
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -475,8 +489,8 @@ async function backfillVersions() {
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- CREATE GROUP FORM -->
|
<!-- CREATE GROUP FORM -->
|
||||||
<div class="border-t border-gray-200 bg-gray-50 p-6">
|
<div class="border-t border-line bg-muted p-6">
|
||||||
<h3 class="mb-4 text-xs font-bold tracking-wide text-gray-500 uppercase">
|
<h3 class="mb-4 text-xs font-bold tracking-wide text-ink-2 uppercase">
|
||||||
{m.admin_section_new_group()}
|
{m.admin_section_new_group()}
|
||||||
</h3>
|
</h3>
|
||||||
<form
|
<form
|
||||||
@@ -491,18 +505,18 @@ async function backfillVersions() {
|
|||||||
name="name"
|
name="name"
|
||||||
placeholder={m.admin_group_name_placeholder()}
|
placeholder={m.admin_group_name_placeholder()}
|
||||||
required
|
required
|
||||||
class="w-full rounded border-gray-300 text-sm"
|
class="w-full rounded border-line text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
{#each availablePermissions as perm (perm)}
|
{#each availablePermissions as perm (perm)}
|
||||||
<label class="inline-flex items-center text-xs font-bold text-gray-600 uppercase">
|
<label class="inline-flex items-center text-xs font-bold text-ink-2 uppercase">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="permissions"
|
name="permissions"
|
||||||
value={perm}
|
value={perm}
|
||||||
class="mr-2 rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
|
class="mr-2 rounded border-line text-ink focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
{perm.replace('_', ' ')}
|
{perm.replace('_', ' ')}
|
||||||
</label>
|
</label>
|
||||||
@@ -511,7 +525,7 @@ async function backfillVersions() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="w-full rounded bg-brand-navy px-6 py-2 text-sm font-bold text-white uppercase hover:bg-brand-mint hover:text-brand-navy md:w-auto"
|
class="w-full rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase hover:bg-accent hover:text-ink md:w-auto"
|
||||||
>
|
>
|
||||||
{m.btn_create()}
|
{m.btn_create()}
|
||||||
</button>
|
</button>
|
||||||
@@ -519,21 +533,40 @@ async function backfillVersions() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if activeTab === 'system'}
|
{:else if activeTab === 'system'}
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-1 text-lg font-bold text-gray-700">{m.admin_system_backfill_heading()}</h2>
|
<h2 class="mb-1 text-lg font-bold text-ink-2">{m.admin_system_backfill_heading()}</h2>
|
||||||
<p class="mb-4 text-sm text-gray-500">{m.admin_system_backfill_description()}</p>
|
<p class="mb-4 text-sm text-ink-2">{m.admin_system_backfill_description()}</p>
|
||||||
<button
|
<button
|
||||||
onclick={backfillVersions}
|
onclick={backfillVersions}
|
||||||
disabled={backfillLoading}
|
disabled={backfillLoading}
|
||||||
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"
|
class="rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{backfillLoading ? '…' : m.admin_system_backfill_btn()}
|
{backfillLoading ? '…' : m.admin_system_backfill_btn()}
|
||||||
</button>
|
</button>
|
||||||
{#if backfillResult !== null}
|
{#if backfillResult !== null}
|
||||||
<p class="mt-4 text-sm font-medium text-brand-navy">
|
<p class="mt-4 text-sm font-medium text-ink">
|
||||||
{m.admin_system_backfill_success({ count: backfillResult })}
|
{m.admin_system_backfill_success({ count: backfillResult })}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
<h2 class="mb-1 text-lg font-bold text-ink-2">
|
||||||
|
{m.admin_system_backfill_hashes_heading()}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-4 text-sm text-ink-2">{m.admin_system_backfill_hashes_description()}</p>
|
||||||
|
<button
|
||||||
|
onclick={backfillFileHashes}
|
||||||
|
disabled={backfillHashesLoading}
|
||||||
|
class="rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-accent hover:text-ink 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-ink">
|
||||||
|
{m.admin_system_backfill_hashes_success({ count: backfillHashesResult })}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function handleBirthDateInput(e: Event) {
|
|||||||
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||||
@@ -55,7 +55,7 @@ function handleBirthDateInput(e: Event) {
|
|||||||
{m.btn_back_to_overview()}
|
{m.btn_back_to_overview()}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy">
|
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">
|
||||||
{m.admin_user_edit_heading({ username: data.editUser.username })}
|
{m.admin_user_edit_heading({ username: data.editUser.username })}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -72,8 +72,8 @@ function handleBirthDateInput(e: Event) {
|
|||||||
|
|
||||||
<form method="POST" use:enhance class="space-y-6">
|
<form method="POST" use:enhance class="space-y-6">
|
||||||
<!-- Profile card -->
|
<!-- Profile card -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.profile_section_personal()}
|
{m.profile_section_personal()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ function handleBirthDateInput(e: Event) {
|
|||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>
|
>
|
||||||
{m.profile_label_first_name()}
|
{m.profile_label_first_name()}
|
||||||
</span>
|
</span>
|
||||||
@@ -89,13 +89,13 @@ function handleBirthDateInput(e: Event) {
|
|||||||
type="text"
|
type="text"
|
||||||
name="firstName"
|
name="firstName"
|
||||||
value={data.editUser.firstName ?? ''}
|
value={data.editUser.firstName ?? ''}
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>
|
>
|
||||||
{m.profile_label_last_name()}
|
{m.profile_label_last_name()}
|
||||||
</span>
|
</span>
|
||||||
@@ -103,15 +103,13 @@ function handleBirthDateInput(e: Event) {
|
|||||||
type="text"
|
type="text"
|
||||||
name="lastName"
|
name="lastName"
|
||||||
value={data.editUser.lastName ?? ''}
|
value={data.editUser.lastName ?? ''}
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
|
||||||
>
|
|
||||||
{m.profile_label_birth_date()}
|
{m.profile_label_birth_date()}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
@@ -119,36 +117,32 @@ function handleBirthDateInput(e: Event) {
|
|||||||
placeholder="TT.MM.JJJJ"
|
placeholder="TT.MM.JJJJ"
|
||||||
value={birthDateDisplay}
|
value={birthDateDisplay}
|
||||||
oninput={handleBirthDateInput}
|
oninput={handleBirthDateInput}
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="birthDate" value={birthDateIso} />
|
<input type="hidden" name="birthDate" value={birthDateIso} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
|
||||||
>
|
|
||||||
{m.profile_label_email()}
|
{m.profile_label_email()}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
value={data.editUser.email ?? ''}
|
value={data.editUser.email ?? ''}
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
|
||||||
>
|
|
||||||
{m.profile_label_contact()}
|
{m.profile_label_contact()}
|
||||||
</span>
|
</span>
|
||||||
<textarea
|
<textarea
|
||||||
name="contact"
|
name="contact"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder={m.profile_contact_placeholder()}
|
placeholder={m.profile_contact_placeholder()}
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
>{data.editUser.contact ?? ''}</textarea
|
>{data.editUser.contact ?? ''}</textarea
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
@@ -156,20 +150,20 @@ function handleBirthDateInput(e: Event) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Groups card -->
|
<!-- Groups card -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.admin_col_groups()}
|
{m.admin_col_groups()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
{#each data.groups as group (group.id)}
|
{#each data.groups as group (group.id)}
|
||||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
|
<label class="inline-flex items-center gap-2 text-sm text-ink-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="groupIds"
|
name="groupIds"
|
||||||
value={group.id}
|
value={group.id}
|
||||||
checked={data.editUser.groups?.some((g: { id: string }) => g.id === group.id)}
|
checked={data.editUser.groups?.some((g: { id: string }) => g.id === group.id)}
|
||||||
class="rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
|
class="rounded border-line text-ink focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
{group.name}
|
{group.name}
|
||||||
</label>
|
</label>
|
||||||
@@ -178,35 +172,31 @@ function handleBirthDateInput(e: Event) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Password card -->
|
<!-- Password card -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.admin_label_new_password_optional()}
|
{m.admin_label_new_password_optional()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
|
||||||
>
|
|
||||||
{m.profile_label_new_password()}
|
{m.profile_label_new_password()}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
name="newPassword"
|
name="newPassword"
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
|
||||||
>
|
|
||||||
{m.profile_label_new_password_confirm()}
|
{m.profile_label_new_password_confirm()}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,17 +204,17 @@ function handleBirthDateInput(e: Event) {
|
|||||||
|
|
||||||
<!-- Save bar -->
|
<!-- Save bar -->
|
||||||
<div
|
<div
|
||||||
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-brand-sand bg-white px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
|
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
class="font-sans text-xs font-bold tracking-widest text-gray-500 uppercase hover:text-brand-navy"
|
class="font-sans text-xs font-bold tracking-widest text-ink-2 uppercase hover:text-ink"
|
||||||
>
|
>
|
||||||
{m.btn_cancel()}
|
{m.btn_cancel()}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
{m.btn_save()}
|
{m.btn_save()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function handleBirthDateInput(e: Event) {
|
|||||||
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||||
@@ -45,7 +45,7 @@ function handleBirthDateInput(e: Event) {
|
|||||||
{m.btn_back_to_overview()}
|
{m.btn_back_to_overview()}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy">{m.admin_user_new_heading()}</h1>
|
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">{m.admin_user_new_heading()}</h1>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
@@ -53,129 +53,115 @@ function handleBirthDateInput(e: Event) {
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<form method="POST" use:enhance class="space-y-5">
|
<form method="POST" use:enhance class="space-y-5">
|
||||||
<!-- Account -->
|
<!-- Account -->
|
||||||
<h2 class="text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.admin_section_users()}
|
{m.admin_section_users()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
|
||||||
>
|
|
||||||
{m.admin_col_login()}
|
{m.admin_col_login()}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="username"
|
name="username"
|
||||||
required
|
required
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
|
||||||
>
|
|
||||||
{m.admin_label_initial_password()}
|
{m.admin_label_initial_password()}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
required
|
required
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Profile -->
|
<!-- Profile -->
|
||||||
<h2 class="pt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="pt-2 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.profile_section_personal()}
|
{m.profile_section_personal()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
|
||||||
>
|
|
||||||
{m.profile_label_first_name()}
|
{m.profile_label_first_name()}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="firstName"
|
name="firstName"
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
|
||||||
>
|
|
||||||
{m.profile_label_last_name()}
|
{m.profile_label_last_name()}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="lastName"
|
name="lastName"
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
|
||||||
>
|
|
||||||
{m.profile_label_birth_date()}
|
{m.profile_label_birth_date()}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="TT.MM.JJJJ"
|
placeholder="TT.MM.JJJJ"
|
||||||
oninput={handleBirthDateInput}
|
oninput={handleBirthDateInput}
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="birthDate" value={birthDateIso} />
|
<input type="hidden" name="birthDate" value={birthDateIso} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
|
||||||
>
|
|
||||||
{m.profile_label_email()}
|
{m.profile_label_email()}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
|
||||||
>
|
|
||||||
{m.profile_label_contact()}
|
{m.profile_label_contact()}
|
||||||
</span>
|
</span>
|
||||||
<textarea
|
<textarea
|
||||||
name="contact"
|
name="contact"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder={m.profile_contact_placeholder()}
|
placeholder={m.profile_contact_placeholder()}
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Groups -->
|
<!-- Groups -->
|
||||||
<h2 class="pt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="pt-2 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.admin_col_groups()}
|
{m.admin_col_groups()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
{#each data.groups as group (group.id)}
|
{#each data.groups as group (group.id)}
|
||||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
|
<label class="inline-flex items-center gap-2 text-sm text-ink-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="groupIds"
|
name="groupIds"
|
||||||
value={group.id}
|
value={group.id}
|
||||||
class="rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
|
class="rounded border-line text-ink focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
{group.name}
|
{group.name}
|
||||||
</label>
|
</label>
|
||||||
@@ -184,17 +170,17 @@ function handleBirthDateInput(e: Event) {
|
|||||||
|
|
||||||
<!-- Save bar -->
|
<!-- Save bar -->
|
||||||
<div
|
<div
|
||||||
class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm"
|
class="mt-4 flex items-center justify-between rounded-sm border border-line bg-surface px-6 py-4 shadow-sm"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
class="font-sans text-xs font-bold tracking-widest text-gray-500 uppercase hover:text-brand-navy"
|
class="font-sans text-xs font-bold tracking-widest text-ink-2 uppercase hover:text-ink"
|
||||||
>
|
>
|
||||||
{m.btn_cancel()}
|
{m.btn_cancel()}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
{m.btn_create()}
|
{m.btn_create()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -67,19 +67,19 @@ const enrichedDocuments = $derived(
|
|||||||
|
|
||||||
<div class="mx-auto max-w-5xl px-4 py-10">
|
<div class="mx-auto max-w-5xl px-4 py-10">
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="mb-8 border-b border-brand-navy/10 pb-4">
|
<div class="mb-8 border-b border-ink/10 pb-4">
|
||||||
<h1 class="font-serif text-3xl font-medium text-brand-navy">{m.conv_heading()}</h1>
|
<h1 class="font-serif text-3xl font-medium text-ink">{m.conv_heading()}</h1>
|
||||||
<p class="mt-2 font-sans text-sm text-brand-navy/60">
|
<p class="mt-2 font-sans text-sm text-ink/60">
|
||||||
{m.conv_subtitle()}
|
{m.conv_subtitle()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FILTER BAR -->
|
<!-- FILTER BAR -->
|
||||||
<div class="relative z-20 mb-10 border border-brand-sand bg-white p-8 shadow-sm">
|
<div class="relative z-20 mb-10 border border-line bg-surface p-8 shadow-sm">
|
||||||
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
|
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
|
||||||
<!-- Sender -->
|
<!-- Sender -->
|
||||||
<div
|
<div
|
||||||
class="relative z-30 [&_input]:border-gray-300 [&_input]:py-2.5 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
|
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||||
>
|
>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="senderId"
|
name="senderId"
|
||||||
@@ -98,7 +98,7 @@ const enrichedDocuments = $derived(
|
|||||||
<button
|
<button
|
||||||
data-testid="conv-swap-btn"
|
data-testid="conv-swap-btn"
|
||||||
onclick={swapPersons}
|
onclick={swapPersons}
|
||||||
class="flex w-full items-center justify-center gap-2 border border-brand-sand px-3 py-2.5 text-xs font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white md:w-auto {senderId &&
|
class="flex w-full items-center justify-center gap-2 border border-line px-3 py-2.5 text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-white md:w-auto {senderId &&
|
||||||
receiverId
|
receiverId
|
||||||
? ''
|
? ''
|
||||||
: 'invisible'}"
|
: 'invisible'}"
|
||||||
@@ -123,7 +123,7 @@ const enrichedDocuments = $derived(
|
|||||||
|
|
||||||
<!-- Receiver -->
|
<!-- Receiver -->
|
||||||
<div
|
<div
|
||||||
class="relative z-30 [&_input]:border-gray-300 [&_input]:py-2.5 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
|
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||||
>
|
>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="receiverId"
|
name="receiverId"
|
||||||
@@ -141,7 +141,7 @@ const enrichedDocuments = $derived(
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="dateFrom"
|
for="dateFrom"
|
||||||
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
|
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
>{m.conv_label_from()}</label
|
>{m.conv_label_from()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -149,7 +149,7 @@ const enrichedDocuments = $derived(
|
|||||||
type="date"
|
type="date"
|
||||||
bind:value={fromDate}
|
bind:value={fromDate}
|
||||||
onchange={() => applyFilters()}
|
onchange={() => applyFilters()}
|
||||||
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ const enrichedDocuments = $derived(
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="dateTo"
|
for="dateTo"
|
||||||
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
|
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
>{m.conv_label_to()}</label
|
>{m.conv_label_to()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -165,7 +165,7 @@ const enrichedDocuments = $derived(
|
|||||||
type="date"
|
type="date"
|
||||||
bind:value={toDate}
|
bind:value={toDate}
|
||||||
onchange={() => applyFilters()}
|
onchange={() => applyFilters()}
|
||||||
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ const enrichedDocuments = $derived(
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onclick={toggleSort}
|
onclick={toggleSort}
|
||||||
class="flex h-[42px] w-full items-center justify-center border border-brand-sand text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
class="flex h-[42px] w-full items-center justify-center border border-line text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-white"
|
||||||
>
|
>
|
||||||
<span class="mr-2">{m.conv_sort_label()}</span>
|
<span class="mr-2">{m.conv_sort_label()}</span>
|
||||||
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
|
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
|
||||||
@@ -196,9 +196,9 @@ const enrichedDocuments = $derived(
|
|||||||
<!-- RESULTS LIST SECTION -->
|
<!-- RESULTS LIST SECTION -->
|
||||||
{#if !senderId || !receiverId}
|
{#if !senderId || !receiverId}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-center rounded-sm border border-dashed border-brand-sand bg-white py-24 text-center"
|
class="flex flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface py-24 text-center"
|
||||||
>
|
>
|
||||||
<div class="mb-4 rounded-full bg-brand-sand/30 p-4 text-brand-navy">
|
<div class="mb-4 rounded-full bg-muted p-4 text-ink">
|
||||||
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -208,25 +208,25 @@ const enrichedDocuments = $derived(
|
|||||||
/></svg
|
/></svg
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p class="font-serif text-lg text-brand-navy">{m.conv_empty_heading()}</p>
|
<p class="font-serif text-lg text-ink">{m.conv_empty_heading()}</p>
|
||||||
<p class="mt-1 font-sans text-sm text-gray-500">{m.conv_empty_text()}</p>
|
<p class="mt-1 font-sans text-sm text-ink-2">{m.conv_empty_text()}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if data.documents.length === 0}
|
{:else if data.documents.length === 0}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-center rounded-sm border border-brand-sand bg-white py-24 text-center shadow-sm"
|
class="flex flex-col items-center justify-center rounded-sm border border-line bg-surface py-24 text-center shadow-sm"
|
||||||
>
|
>
|
||||||
<p class="font-serif text-brand-navy">{m.conv_no_results_heading()}</p>
|
<p class="font-serif text-ink">{m.conv_no_results_heading()}</p>
|
||||||
<p class="mt-2 text-sm text-gray-400">{m.conv_no_results_text()}</p>
|
<p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Summary bar -->
|
<!-- Summary bar -->
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
{#if yearFrom !== null && yearTo !== null}
|
{#if yearFrom !== null && yearTo !== null}
|
||||||
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-brand-navy/70">
|
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
|
||||||
{m.conv_summary({ count: data.documents.length, yearFrom, yearTo })}
|
{m.conv_summary({ count: data.documents.length, yearFrom, yearTo })}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-brand-navy/70">
|
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
|
||||||
{data.documents.length}
|
{data.documents.length}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -234,7 +234,7 @@ const enrichedDocuments = $derived(
|
|||||||
<a
|
<a
|
||||||
data-testid="conv-new-doc-link"
|
data-testid="conv-new-doc-link"
|
||||||
href="/documents/new?senderId={senderId}&receiverId={receiverId}"
|
href="/documents/new?senderId={senderId}&receiverId={receiverId}"
|
||||||
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
class="inline-flex items-center gap-1 text-sm font-medium text-ink/60 transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"
|
||||||
@@ -246,10 +246,10 @@ const enrichedDocuments = $derived(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CHAT CONTAINER -->
|
<!-- CHAT CONTAINER -->
|
||||||
<div class="relative overflow-hidden rounded-sm border border-brand-sand bg-white shadow-sm">
|
<div class="relative overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||||
<!-- Decoration: Central Timeline Line -->
|
<!-- Decoration: Central Timeline Line -->
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-brand-sand/30 md:block"
|
class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-muted md:block"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="p-6 md:p-8">
|
<div class="p-6 md:p-8">
|
||||||
@@ -257,12 +257,11 @@ const enrichedDocuments = $derived(
|
|||||||
{#each enrichedDocuments as { doc, year, showYearDivider } (doc.id)}
|
{#each enrichedDocuments as { doc, year, showYearDivider } (doc.id)}
|
||||||
{#if showYearDivider}
|
{#if showYearDivider}
|
||||||
<div data-testid="year-divider" class="relative flex items-center py-2 text-center">
|
<div data-testid="year-divider" class="relative flex items-center py-2 text-center">
|
||||||
<div class="flex-grow border-t border-brand-sand"></div>
|
<div class="flex-grow border-t border-line"></div>
|
||||||
<span
|
<span class="mx-4 font-sans text-xs font-bold tracking-widest text-ink/40 uppercase"
|
||||||
class="mx-4 font-sans text-xs font-bold tracking-widest text-brand-navy/40 uppercase"
|
|
||||||
>{year}</span
|
>{year}</span
|
||||||
>
|
>
|
||||||
<div class="flex-grow border-t border-brand-sand"></div>
|
<div class="flex-grow border-t border-line"></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{@const isRight = doc.sender?.id === senderId}
|
{@const isRight = doc.sender?.id === senderId}
|
||||||
@@ -280,8 +279,8 @@ const enrichedDocuments = $derived(
|
|||||||
<div
|
<div
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-full border font-serif text-xs shadow-sm
|
class="flex h-8 w-8 items-center justify-center rounded-full border font-serif text-xs shadow-sm
|
||||||
{isRight
|
{isRight
|
||||||
? 'border-brand-navy bg-brand-navy text-white'
|
? 'border-primary bg-primary text-primary-fg'
|
||||||
: 'border-brand-sand bg-white text-brand-navy'}"
|
: 'border-line bg-surface text-ink'}"
|
||||||
>
|
>
|
||||||
{#if doc.sender}
|
{#if doc.sender}
|
||||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||||
@@ -296,15 +295,15 @@ const enrichedDocuments = $derived(
|
|||||||
href="/documents/{doc.id}"
|
href="/documents/{doc.id}"
|
||||||
class="group block transform rounded border p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md
|
class="group block transform rounded border p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md
|
||||||
{isRight
|
{isRight
|
||||||
? 'rounded-br-none border-brand-navy bg-brand-navy text-white'
|
? 'rounded-br-none border-primary bg-primary text-primary-fg'
|
||||||
: 'rounded-bl-none border-brand-sand bg-brand-sand/10 text-brand-navy'}"
|
: 'rounded-bl-none border-line bg-muted/50 text-ink'}"
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-2 flex items-start justify-between gap-4">
|
<div class="mb-2 flex items-start justify-between gap-4">
|
||||||
<h3
|
<h3
|
||||||
class="font-serif text-sm leading-snug font-medium {isRight
|
class="font-serif text-sm leading-snug font-medium {isRight
|
||||||
? 'text-white'
|
? 'text-primary-fg'
|
||||||
: 'text-brand-navy'}"
|
: 'text-ink'}"
|
||||||
>
|
>
|
||||||
{doc.title || doc.originalFilename}
|
{doc.title || doc.originalFilename}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -313,7 +312,7 @@ const enrichedDocuments = $derived(
|
|||||||
<span
|
<span
|
||||||
class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full
|
class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full
|
||||||
{doc.status === 'UPLOADED'
|
{doc.status === 'UPLOADED'
|
||||||
? 'bg-brand-mint'
|
? 'bg-accent'
|
||||||
: 'bg-yellow-400'}"
|
: 'bg-yellow-400'}"
|
||||||
title={doc.status}
|
title={doc.status}
|
||||||
>
|
>
|
||||||
@@ -323,8 +322,8 @@ const enrichedDocuments = $derived(
|
|||||||
<!-- Metadata -->
|
<!-- Metadata -->
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-3 font-sans text-[10px] tracking-wider uppercase opacity-80 {isRight
|
class="flex flex-wrap gap-3 font-sans text-[10px] tracking-wider uppercase opacity-80 {isRight
|
||||||
? 'text-blue-100'
|
? 'text-primary-fg/70'
|
||||||
: 'text-gray-500'}"
|
: 'text-ink-2'}"
|
||||||
>
|
>
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { onMount } from 'svelte';
|
|||||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
||||||
|
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
|
||||||
|
|
||||||
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||||
|
|
||||||
@@ -56,53 +57,59 @@ async function loadFile(id: string) {
|
|||||||
|
|
||||||
let annotateMode = $state(false);
|
let annotateMode = $state(false);
|
||||||
let activeAnnotationId = $state<string | null>(null);
|
let activeAnnotationId = $state<string | null>(null);
|
||||||
|
let activeAnnotationPage = $state<number | null>(null);
|
||||||
|
|
||||||
// When an annotation is clicked, open the Diskussion tab.
|
// Close the panel when entering annotate mode so the PDF is fully visible.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (activeAnnotationId) {
|
if (annotateMode) panelOpen = false;
|
||||||
activeTab = 'discussion';
|
|
||||||
panelOpen = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Bottom panel state ────────────────────────────────────────────────────────
|
// ── Bottom panel state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const LS_KEY_OPEN = 'doc-panel-open';
|
|
||||||
const LS_KEY_HEIGHT = 'doc-panel-height';
|
const LS_KEY_HEIGHT = 'doc-panel-height';
|
||||||
const LS_KEY_TAB = 'doc-panel-tab';
|
const LS_KEY_TAB = 'doc-panel-tab';
|
||||||
|
|
||||||
let panelOpen = $state(false);
|
let panelOpen = $state(false);
|
||||||
let panelHeight = $state(320);
|
let panelHeight = $state(0); // set to full height on mount
|
||||||
|
let navHeight = $state(0);
|
||||||
let activeTab = $state<Tab>('metadata');
|
let activeTab = $state<Tab>('metadata');
|
||||||
let localStorageRestored = $state(false);
|
let localStorageRestored = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const savedOpen = localStorage.getItem(LS_KEY_OPEN);
|
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
||||||
|
|
||||||
const savedHeight = localStorage.getItem(LS_KEY_HEIGHT);
|
const savedHeight = localStorage.getItem(LS_KEY_HEIGHT);
|
||||||
const savedTab = localStorage.getItem(LS_KEY_TAB);
|
const savedTab = localStorage.getItem(LS_KEY_TAB);
|
||||||
|
|
||||||
if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) {
|
if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) {
|
||||||
activeTab = savedTab as Tab;
|
activeTab = savedTab as Tab;
|
||||||
}
|
}
|
||||||
|
const topbar = document.querySelector('[data-topbar]');
|
||||||
|
panelHeight = window.innerHeight - navHeight - (topbar?.getBoundingClientRect().height ?? 0);
|
||||||
if (savedHeight) {
|
if (savedHeight) {
|
||||||
const h = parseInt(savedHeight, 10);
|
const h = parseInt(savedHeight, 10);
|
||||||
if (!isNaN(h) && h >= 80) panelHeight = h;
|
if (!isNaN(h) && h >= 80) panelHeight = h;
|
||||||
}
|
}
|
||||||
if (savedOpen !== null) {
|
|
||||||
panelOpen = savedOpen === 'true';
|
|
||||||
} else if (!doc.filePath) {
|
|
||||||
// No previous state and no file → open to Metadaten by default
|
|
||||||
panelOpen = true;
|
|
||||||
activeTab = 'metadata';
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorageRestored = true;
|
localStorageRestored = true;
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (activeAnnotationId) {
|
||||||
|
activeAnnotationId = null;
|
||||||
|
activeAnnotationPage = null;
|
||||||
|
} else if (panelOpen) {
|
||||||
|
panelOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Persist panel state whenever it changes (after initial restore).
|
// Persist panel state whenever it changes (after initial restore).
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!localStorageRestored) return;
|
if (!localStorageRestored) return;
|
||||||
localStorage.setItem(LS_KEY_OPEN, String(panelOpen));
|
|
||||||
localStorage.setItem(LS_KEY_HEIGHT, String(panelHeight));
|
localStorage.setItem(LS_KEY_HEIGHT, String(panelHeight));
|
||||||
localStorage.setItem(LS_KEY_TAB, activeTab);
|
localStorage.setItem(LS_KEY_TAB, activeTab);
|
||||||
});
|
});
|
||||||
@@ -112,7 +119,11 @@ $effect(() => {
|
|||||||
<title>{doc.title || doc.originalFilename || 'Dokument'}</title>
|
<title>{doc.title || doc.originalFilename || 'Dokument'}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex h-screen flex-col overflow-hidden bg-white" data-hydrated>
|
<div
|
||||||
|
class="fixed right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden bg-surface"
|
||||||
|
style="top: {navHeight}px"
|
||||||
|
data-hydrated
|
||||||
|
>
|
||||||
<DocumentTopBar
|
<DocumentTopBar
|
||||||
doc={doc}
|
doc={doc}
|
||||||
canWrite={data.canWrite ?? false}
|
canWrite={data.canWrite ?? false}
|
||||||
@@ -129,21 +140,33 @@ $effect(() => {
|
|||||||
error={fileError}
|
error={fileError}
|
||||||
bind:annotateMode={annotateMode}
|
bind:annotateMode={annotateMode}
|
||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
|
bind:activeAnnotationPage={activeAnnotationPage}
|
||||||
onAnnotationClick={(id) => {
|
onAnnotationClick={(id) => {
|
||||||
activeAnnotationId = id;
|
activeAnnotationId = id;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<AnnotationSidePanel
|
||||||
|
documentId={doc.id}
|
||||||
|
activeAnnotationId={activeAnnotationId}
|
||||||
|
activeAnnotationPage={activeAnnotationPage}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
onClose={() => {
|
||||||
|
activeAnnotationId = null;
|
||||||
|
activeAnnotationPage = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentBottomPanel
|
<DocumentBottomPanel
|
||||||
doc={doc}
|
doc={doc}
|
||||||
comments={(data.comments ?? []) as never[]}
|
comments={(data.comments ?? []) as never[]}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canAdmin={canAdmin}
|
canAdmin={canAdmin}
|
||||||
bind:open={panelOpen}
|
bind:open={panelOpen}
|
||||||
bind:height={panelHeight}
|
bind:height={panelHeight}
|
||||||
bind:activeTab={activeTab}
|
bind:activeTab={activeTab}
|
||||||
activeAnnotationId={activeAnnotationId}
|
/>
|
||||||
/>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function handleDateInput(e: Event) {
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}"
|
href="/documents/{doc.id}"
|
||||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||||
@@ -53,9 +53,9 @@ function handleDateInput(e: Event) {
|
|||||||
/>
|
/>
|
||||||
{m.btn_back_to_document()}
|
{m.btn_back_to_document()}
|
||||||
</a>
|
</a>
|
||||||
<h1 class="font-serif text-3xl text-brand-navy">
|
<h1 class="font-serif text-3xl text-ink">
|
||||||
{m.doc_edit_heading()} —
|
{m.doc_edit_heading()} —
|
||||||
<span class="text-brand-navy/70">{doc.title || doc.originalFilename}</span>
|
<span class="text-ink/70">{doc.title || doc.originalFilename}</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,15 +65,15 @@ function handleDateInput(e: Event) {
|
|||||||
|
|
||||||
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
||||||
<!-- ── Section 1: Wer & Wann ── -->
|
<!-- ── Section 1: Wer & Wann ── -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.doc_section_who_when()}
|
{m.doc_section_who_when()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
<!-- Datum -->
|
<!-- Datum -->
|
||||||
<div>
|
<div>
|
||||||
<label for="documentDate" class="mb-1 block text-sm font-medium text-gray-700"
|
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.form_label_date()}</label
|
>{m.form_label_date()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -84,8 +84,8 @@ function handleDateInput(e: Event) {
|
|||||||
oninput={handleDateInput}
|
oninput={handleDateInput}
|
||||||
placeholder={m.form_placeholder_date()}
|
placeholder={m.form_placeholder_date()}
|
||||||
maxlength="10"
|
maxlength="10"
|
||||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm
|
||||||
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-brand-navy focus:ring-brand-navy'}"
|
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-ink focus:ring-ink'}"
|
||||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="documentDate" value={dateIso} />
|
<input type="hidden" name="documentDate" value={dateIso} />
|
||||||
@@ -96,7 +96,7 @@ function handleDateInput(e: Event) {
|
|||||||
|
|
||||||
<!-- Ort -->
|
<!-- Ort -->
|
||||||
<div>
|
<div>
|
||||||
<label for="location" class="mb-1 block text-sm font-medium text-gray-700"
|
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.form_label_location()}</label
|
>{m.form_label_location()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -105,7 +105,7 @@ function handleDateInput(e: Event) {
|
|||||||
name="location"
|
name="location"
|
||||||
value={doc.location || ''}
|
value={doc.location || ''}
|
||||||
placeholder={m.form_placeholder_location()}
|
placeholder={m.form_placeholder_location()}
|
||||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -121,22 +121,22 @@ function handleDateInput(e: Event) {
|
|||||||
|
|
||||||
<!-- Empfänger -->
|
<!-- Empfänger -->
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_receivers()}</p>
|
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_receivers()}</p>
|
||||||
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
|
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Section 2: Beschreibung ── -->
|
<!-- ── Section 2: Beschreibung ── -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.doc_section_description()}
|
{m.doc_section_description()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<!-- Titel -->
|
<!-- Titel -->
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="mb-1 block text-sm font-medium text-gray-700"
|
<label for="title" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.form_label_title()} *</label
|
>{m.form_label_title()} *</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -145,13 +145,13 @@ function handleDateInput(e: Event) {
|
|||||||
name="title"
|
name="title"
|
||||||
value={doc.title || ''}
|
value={doc.title || ''}
|
||||||
required
|
required
|
||||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Aufbewahrungsort -->
|
<!-- Aufbewahrungsort -->
|
||||||
<div>
|
<div>
|
||||||
<label for="documentLocation" class="mb-1 block text-sm font-medium text-gray-700"
|
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.form_label_archive_location()}</label
|
>{m.form_label_archive_location()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -160,21 +160,21 @@ function handleDateInput(e: Event) {
|
|||||||
name="documentLocation"
|
name="documentLocation"
|
||||||
value={doc.documentLocation || ''}
|
value={doc.documentLocation || ''}
|
||||||
placeholder={m.form_placeholder_archive_location()}
|
placeholder={m.form_placeholder_archive_location()}
|
||||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-gray-400">{m.form_helper_archive_location()}</p>
|
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Schlagworte -->
|
<!-- Schlagworte -->
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_tags()}</p>
|
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p>
|
||||||
<TagInput bind:tags={tags} />
|
<TagInput bind:tags={tags} />
|
||||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Inhalt -->
|
<!-- Inhalt -->
|
||||||
<div>
|
<div>
|
||||||
<label for="summary" class="mb-1 block text-sm font-medium text-gray-700"
|
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.form_label_content()}</label
|
>{m.form_label_content()}</label
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -182,7 +182,7 @@ function handleDateInput(e: Event) {
|
|||||||
name="summary"
|
name="summary"
|
||||||
rows="5"
|
rows="5"
|
||||||
placeholder={m.form_placeholder_content()}
|
placeholder={m.form_placeholder_content()}
|
||||||
class="block w-full rounded border border-gray-300 p-2 font-serif text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
>{doc.summary || ''}</textarea
|
>{doc.summary || ''}</textarea
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,8 +190,8 @@ function handleDateInput(e: Event) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Section 3: Transkription ── -->
|
<!-- ── Section 3: Transkription ── -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.form_label_transcription()}
|
{m.form_label_transcription()}
|
||||||
</h2>
|
</h2>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -199,20 +199,18 @@ function handleDateInput(e: Event) {
|
|||||||
name="transcription"
|
name="transcription"
|
||||||
rows="12"
|
rows="12"
|
||||||
placeholder={m.form_placeholder_transcription()}
|
placeholder={m.form_placeholder_transcription()}
|
||||||
class="block w-full rounded border border-gray-300 p-2 font-serif text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
>{doc.transcription || ''}</textarea
|
>{doc.transcription || ''}</textarea
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Section 4: Datei ── -->
|
<!-- ── Section 4: Datei ── -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.doc_section_file()}
|
{m.doc_section_file()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div
|
<div class="mb-4 flex items-center gap-3 rounded bg-muted px-3 py-2 text-sm text-ink-2">
|
||||||
class="mb-4 flex items-center gap-3 rounded bg-brand-sand/20 px-3 py-2 text-sm text-gray-600"
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -221,40 +219,40 @@ function handleDateInput(e: Event) {
|
|||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
>{m.doc_current_file_label()}
|
>{m.doc_current_file_label()}
|
||||||
<strong class="font-medium text-brand-navy">{doc.originalFilename}</strong></span
|
<strong class="font-medium text-ink">{doc.originalFilename}</strong></span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label for="file-upload" class="mb-1 block text-sm font-medium text-gray-700">
|
<label for="file-upload" class="mb-1 block text-sm font-medium text-ink-2">
|
||||||
{m.doc_file_replace_label()}
|
{m.doc_file_replace_label()}
|
||||||
<span class="font-normal text-gray-400">({m.doc_file_replace_note()})</span>
|
<span class="font-normal text-ink-3">({m.doc_file_replace_note()})</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="file-upload"
|
id="file-upload"
|
||||||
type="file"
|
type="file"
|
||||||
name="file"
|
name="file"
|
||||||
class="block w-full cursor-pointer text-sm
|
class="block w-full cursor-pointer text-sm
|
||||||
text-gray-500 file:mr-4 file:rounded
|
text-ink-2 file:mr-4 file:rounded
|
||||||
file:border-0 file:bg-brand-sand/40
|
file:border-0 file:bg-muted
|
||||||
file:px-4 file:py-2
|
file:px-4 file:py-2
|
||||||
file:text-sm file:font-semibold
|
file:text-sm file:font-semibold
|
||||||
file:text-brand-navy hover:file:bg-brand-sand/60"
|
file:text-ink hover:file:bg-muted"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Sticky Save Bar ── -->
|
<!-- ── Sticky Save Bar ── -->
|
||||||
<div
|
<div
|
||||||
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-brand-sand bg-white px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
|
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}"
|
href="/documents/{doc.id}"
|
||||||
class="text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
|
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
{m.btn_cancel()}
|
{m.btn_cancel()}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/80"
|
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
|
||||||
>
|
>
|
||||||
{m.btn_save()}
|
{m.btn_save()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function handleDateInput(e: Event) {
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||||
@@ -67,7 +67,7 @@ function handleDateInput(e: Event) {
|
|||||||
</svg>
|
</svg>
|
||||||
{m.btn_back_to_overview()}
|
{m.btn_back_to_overview()}
|
||||||
</a>
|
</a>
|
||||||
<h1 class="font-serif text-3xl text-brand-navy">{m.doc_new_heading()}</h1>
|
<h1 class="font-serif text-3xl text-ink">{m.doc_new_heading()}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
@@ -76,15 +76,15 @@ function handleDateInput(e: Event) {
|
|||||||
|
|
||||||
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
||||||
<!-- ── Section 1: Wer & Wann ── -->
|
<!-- ── Section 1: Wer & Wann ── -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.doc_section_who_when()}
|
{m.doc_section_who_when()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
<!-- Datum -->
|
<!-- Datum -->
|
||||||
<div>
|
<div>
|
||||||
<label for="documentDate" class="mb-1 block text-sm font-medium text-gray-700"
|
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.form_label_date()}</label
|
>{m.form_label_date()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -95,8 +95,8 @@ function handleDateInput(e: Event) {
|
|||||||
oninput={handleDateInput}
|
oninput={handleDateInput}
|
||||||
placeholder={m.form_placeholder_date()}
|
placeholder={m.form_placeholder_date()}
|
||||||
maxlength="10"
|
maxlength="10"
|
||||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm
|
||||||
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-brand-navy focus:ring-brand-navy'}"
|
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-ink focus:ring-ink'}"
|
||||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="documentDate" value={dateIso} />
|
<input type="hidden" name="documentDate" value={dateIso} />
|
||||||
@@ -109,7 +109,7 @@ function handleDateInput(e: Event) {
|
|||||||
|
|
||||||
<!-- Ort -->
|
<!-- Ort -->
|
||||||
<div>
|
<div>
|
||||||
<label for="location" class="mb-1 block text-sm font-medium text-gray-700"
|
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.form_label_location()}</label
|
>{m.form_label_location()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -117,7 +117,7 @@ function handleDateInput(e: Event) {
|
|||||||
type="text"
|
type="text"
|
||||||
name="location"
|
name="location"
|
||||||
placeholder={m.form_placeholder_location()}
|
placeholder={m.form_placeholder_location()}
|
||||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,22 +133,22 @@ function handleDateInput(e: Event) {
|
|||||||
|
|
||||||
<!-- Empfänger -->
|
<!-- Empfänger -->
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_receivers()}</p>
|
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_receivers()}</p>
|
||||||
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
|
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Section 2: Beschreibung ── -->
|
<!-- ── Section 2: Beschreibung ── -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.doc_section_description()}
|
{m.doc_section_description()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<!-- Titel -->
|
<!-- Titel -->
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="mb-1 block text-sm font-medium text-gray-700"
|
<label for="title" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.form_label_title()} *</label
|
>{m.form_label_title()} *</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -156,13 +156,13 @@ function handleDateInput(e: Event) {
|
|||||||
type="text"
|
type="text"
|
||||||
name="title"
|
name="title"
|
||||||
required
|
required
|
||||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Aufbewahrungsort -->
|
<!-- Aufbewahrungsort -->
|
||||||
<div>
|
<div>
|
||||||
<label for="documentLocation" class="mb-1 block text-sm font-medium text-gray-700"
|
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.form_label_archive_location()}</label
|
>{m.form_label_archive_location()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -170,21 +170,21 @@ function handleDateInput(e: Event) {
|
|||||||
type="text"
|
type="text"
|
||||||
name="documentLocation"
|
name="documentLocation"
|
||||||
placeholder={m.form_placeholder_archive_location()}
|
placeholder={m.form_placeholder_archive_location()}
|
||||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-gray-400">{m.form_helper_archive_location()}</p>
|
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Schlagworte -->
|
<!-- Schlagworte -->
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_tags()}</p>
|
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p>
|
||||||
<TagInput bind:tags={tags} />
|
<TagInput bind:tags={tags} />
|
||||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Inhalt -->
|
<!-- Inhalt -->
|
||||||
<div>
|
<div>
|
||||||
<label for="summary" class="mb-1 block text-sm font-medium text-gray-700"
|
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.form_label_content()}</label
|
>{m.form_label_content()}</label
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -192,15 +192,15 @@ function handleDateInput(e: Event) {
|
|||||||
name="summary"
|
name="summary"
|
||||||
rows="5"
|
rows="5"
|
||||||
placeholder={m.form_placeholder_content()}
|
placeholder={m.form_placeholder_content()}
|
||||||
class="block w-full rounded border border-gray-300 p-2 font-serif text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Section 3: Transkription ── -->
|
<!-- ── Section 3: Transkription ── -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.form_label_transcription()}
|
{m.form_label_transcription()}
|
||||||
</h2>
|
</h2>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -208,43 +208,43 @@ function handleDateInput(e: Event) {
|
|||||||
name="transcription"
|
name="transcription"
|
||||||
rows="12"
|
rows="12"
|
||||||
placeholder={m.form_placeholder_transcription()}
|
placeholder={m.form_placeholder_transcription()}
|
||||||
class="block w-full rounded border border-gray-300 p-2 font-serif text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Section 4: Datei ── -->
|
<!-- ── Section 4: Datei ── -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.doc_section_file()}
|
{m.doc_section_file()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<label for="file-upload" class="mb-1 block text-sm font-medium text-gray-700">
|
<label for="file-upload" class="mb-1 block text-sm font-medium text-ink-2">
|
||||||
{m.doc_file_upload_label()}
|
{m.doc_file_upload_label()}
|
||||||
<span class="font-normal text-gray-400">({m.doc_file_upload_note()})</span>
|
<span class="font-normal text-ink-3">({m.doc_file_upload_note()})</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="file-upload"
|
id="file-upload"
|
||||||
type="file"
|
type="file"
|
||||||
name="file"
|
name="file"
|
||||||
class="block w-full cursor-pointer text-sm
|
class="block w-full cursor-pointer text-sm
|
||||||
text-gray-500 file:mr-4 file:rounded
|
text-ink-2 file:mr-4 file:rounded
|
||||||
file:border-0 file:bg-brand-sand/40
|
file:border-0 file:bg-muted
|
||||||
file:px-4 file:py-2
|
file:px-4 file:py-2
|
||||||
file:text-sm file:font-semibold
|
file:text-sm file:font-semibold
|
||||||
file:text-brand-navy hover:file:bg-brand-sand/60"
|
file:text-ink hover:file:bg-muted"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Sticky Save Bar ── -->
|
<!-- ── Sticky Save Bar ── -->
|
||||||
<div
|
<div
|
||||||
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-brand-sand bg-white px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
|
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
|
||||||
>
|
>
|
||||||
<a href="/" class="text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy">
|
<a href="/" class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
|
||||||
{m.btn_cancel()}
|
{m.btn_cancel()}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/80"
|
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
|
||||||
>
|
>
|
||||||
{m.btn_save()}
|
{m.btn_save()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative flex min-h-screen flex-col bg-white">
|
<div class="relative flex min-h-screen flex-col bg-surface">
|
||||||
<!-- Accent strip -->
|
<!-- Accent strip -->
|
||||||
<div class="h-1 bg-brand-purple"></div>
|
<div class="h-1 bg-brand-purple"></div>
|
||||||
|
|
||||||
@@ -13,15 +13,15 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
|||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="mb-10 text-center">
|
<div class="mb-10 text-center">
|
||||||
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
||||||
<span class="font-sans text-2xl font-bold tracking-widest text-brand-navy uppercase"
|
<span class="font-sans text-2xl font-bold tracking-widest text-ink uppercase"
|
||||||
>Familienarchiv</span
|
>Familienarchiv</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card -->
|
<!-- Card -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-8 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-8 shadow-sm">
|
||||||
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-brand-navy uppercase">
|
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-ink uppercase">
|
||||||
{m.forgot_password_heading()}
|
{m.forgot_password_heading()}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -30,9 +30,7 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
|||||||
<p class="font-sans text-xs text-green-700">{m.forgot_password_success()}</p>
|
<p class="font-sans text-xs text-green-700">{m.forgot_password_success()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a href="/login" class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
href="/login"
|
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
||||||
>{m.forgot_password_back_to_login()}</a
|
>{m.forgot_password_back_to_login()}</a
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -40,7 +38,7 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="email"
|
for="email"
|
||||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
>{m.forgot_password_email_label()}</label
|
>{m.forgot_password_email_label()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -49,7 +47,7 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
|||||||
id="email"
|
id="email"
|
||||||
required
|
required
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -59,15 +57,13 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="mt-2 w-full bg-brand-navy py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
{m.forgot_password_submit()}
|
{m.forgot_password_submit()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="mt-4 text-center">
|
<div class="mt-4 text-center">
|
||||||
<a
|
<a href="/login" class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
href="/login"
|
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
||||||
>{m.forgot_password_back_to_login()}</a
|
>{m.forgot_password_back_to_login()}</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,6 +75,6 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="py-4 text-center">
|
<div class="py-4 text-center">
|
||||||
<p class="font-sans text-xs tracking-widest text-gray-300 uppercase">Familienarchiv</p>
|
<p class="font-sans text-xs tracking-widest text-ink-3 uppercase">Familienarchiv</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,176 @@
|
|||||||
/* 1. Import Tailwind (replaces @tailwind base/components/utilities) */
|
/* ─── 1. Fonts & Tailwind ──────────────────────────────────────────────────── */
|
||||||
/* Fonts: Montserrat = Gotham substitute | Tinos = Times substitute (De Gruyter Brill CI) */
|
/* Tinos = Times substitute | Montserrat = Gotham substitute (De Gruyter Brill CI) */
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400;1,700&family=Montserrat:wght@400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400;1,700&family=Montserrat:wght@400;500;600;700&display=swap');
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
/* 2. Define Custom Theme Variables — De Gruyter Brill CI */
|
/* ─── 2. Raw palette — never used directly in components ──────────────────── */
|
||||||
@theme {
|
@theme {
|
||||||
/* COLORS — exact De Gruyter Brill brand palette */
|
/* Brand palette constants */
|
||||||
--color-brand-navy: #012851; /* Prussian Blue */
|
--palette-navy: #012851;
|
||||||
--color-brand-mint: #a1dcd8; /* Aqua Island */
|
--palette-mint: #a1dcd8;
|
||||||
--color-brand-purple: #b4b9ff; /* Melrose */
|
--palette-turquoise: #00c7b1;
|
||||||
--color-brand-sand: #f0efe9; /* Neutral paper tone */
|
--palette-sand: #f0efe9;
|
||||||
--color-brand-white: #ffffff;
|
--palette-purple: #b4b9ff;
|
||||||
--color-brand-dark: #0d0d0d;
|
|
||||||
|
|
||||||
/* FONTS */
|
/* Typography */
|
||||||
--font-sans: 'Montserrat', ui-sans-serif, system-ui, sans-serif;
|
--font-sans: 'Montserrat', ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-serif: 'Tinos', 'Times New Roman', Georgia, serif;
|
--font-serif: 'Tinos', 'Times New Roman', Georgia, serif;
|
||||||
|
|
||||||
--text-huge: 4rem;
|
--text-huge: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 3. Base Styles */
|
/* ─── 3. Semantic tokens — Tailwind utilities backed by CSS variables ──────── */
|
||||||
|
/*
|
||||||
|
@theme inline makes Tailwind generate utility classes (bg-surface, text-ink,
|
||||||
|
border-line, etc.) whose values are CSS custom properties, not hardcoded hex.
|
||||||
|
Changing --c-surface on :root is all it takes to retheme the whole UI.
|
||||||
|
*/
|
||||||
|
@theme inline {
|
||||||
|
/* Surfaces */
|
||||||
|
--color-canvas: var(--c-canvas);
|
||||||
|
--color-surface: var(--c-surface);
|
||||||
|
--color-overlay: var(--c-overlay);
|
||||||
|
--color-muted: var(--c-muted);
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--color-line: var(--c-line);
|
||||||
|
--color-line-2: var(--c-line-2);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-ink: var(--c-ink);
|
||||||
|
--color-ink-2: var(--c-ink-2);
|
||||||
|
--color-ink-3: var(--c-ink-3);
|
||||||
|
|
||||||
|
/* Accent (mint ↔ turquoise) */
|
||||||
|
--color-accent: var(--c-accent);
|
||||||
|
--color-accent-bg: var(--c-accent-bg);
|
||||||
|
|
||||||
|
/* Primary interactive (navy ↔ mint) */
|
||||||
|
--color-primary: var(--c-primary);
|
||||||
|
--color-primary-fg: var(--c-primary-fg);
|
||||||
|
|
||||||
|
/* Nav active state */
|
||||||
|
--color-nav-active: var(--c-nav-active);
|
||||||
|
|
||||||
|
/* PDF viewer */
|
||||||
|
--color-pdf-bg: var(--c-pdf-bg);
|
||||||
|
--color-pdf-ctrl: var(--c-pdf-ctrl);
|
||||||
|
--color-pdf-text: var(--c-pdf-text);
|
||||||
|
|
||||||
|
/* Static brand tokens (not themed) */
|
||||||
|
--color-brand-purple: var(--palette-purple);
|
||||||
|
--color-brand-navy: var(--palette-navy);
|
||||||
|
--color-brand-mint: var(--palette-mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 4. Light mode (default) ─────────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--c-canvas: #f0efe9;
|
||||||
|
--c-surface: #ffffff;
|
||||||
|
--c-overlay: #ffffff;
|
||||||
|
--c-muted: #f5f4ef;
|
||||||
|
|
||||||
|
--c-line: #e4e2d7;
|
||||||
|
--c-line-2: #eeede8;
|
||||||
|
|
||||||
|
--c-ink: #012851;
|
||||||
|
--c-ink-2: #6b7280;
|
||||||
|
--c-ink-3: #9ca3af;
|
||||||
|
|
||||||
|
--c-accent: #a1dcd8;
|
||||||
|
--c-accent-bg: rgba(161, 220, 216, 0.15);
|
||||||
|
|
||||||
|
--c-primary: #012851;
|
||||||
|
--c-primary-fg: #ffffff;
|
||||||
|
|
||||||
|
--c-nav-active: rgba(180, 185, 255, 0.15);
|
||||||
|
|
||||||
|
--c-pdf-bg: #ebebeb;
|
||||||
|
--c-pdf-ctrl: #d8d8d8;
|
||||||
|
--c-pdf-text: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme='light']) {
|
||||||
|
--c-canvas: #0d0d0d;
|
||||||
|
--c-surface: #1a1a1a;
|
||||||
|
--c-overlay: #242424;
|
||||||
|
--c-muted: #252525;
|
||||||
|
|
||||||
|
--c-line: #2e2e2e;
|
||||||
|
--c-line-2: #222222;
|
||||||
|
|
||||||
|
--c-ink: #f0efe9;
|
||||||
|
--c-ink-2: #9ca3af;
|
||||||
|
--c-ink-3: #6b7280;
|
||||||
|
|
||||||
|
--c-accent: #00c7b1;
|
||||||
|
--c-accent-bg: rgba(0, 199, 177, 0.12);
|
||||||
|
|
||||||
|
--c-primary: #a1dcd8;
|
||||||
|
--c-primary-fg: #012851;
|
||||||
|
|
||||||
|
--c-nav-active: rgba(180, 185, 255, 0.12);
|
||||||
|
|
||||||
|
--c-pdf-bg: #1e1e1e;
|
||||||
|
--c-pdf-ctrl: #2a2a2a;
|
||||||
|
--c-pdf-text: #d1d1d1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual dark override — takes precedence over media query */
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--c-canvas: #0d0d0d;
|
||||||
|
--c-surface: #1a1a1a;
|
||||||
|
--c-overlay: #242424;
|
||||||
|
--c-muted: #252525;
|
||||||
|
|
||||||
|
--c-line: #2e2e2e;
|
||||||
|
--c-line-2: #222222;
|
||||||
|
|
||||||
|
--c-ink: #f0efe9;
|
||||||
|
--c-ink-2: #9ca3af;
|
||||||
|
--c-ink-3: #6b7280;
|
||||||
|
|
||||||
|
--c-accent: #00c7b1;
|
||||||
|
--c-accent-bg: rgba(0, 199, 177, 0.12);
|
||||||
|
|
||||||
|
--c-primary: #a1dcd8;
|
||||||
|
--c-primary-fg: #012851;
|
||||||
|
|
||||||
|
--c-nav-active: rgba(180, 185, 255, 0.12);
|
||||||
|
|
||||||
|
--c-pdf-bg: #1e1e1e;
|
||||||
|
--c-pdf-ctrl: #2a2a2a;
|
||||||
|
--c-pdf-text: #d1d1d1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||||
|
/*
|
||||||
|
In dark mode, invert all brand icons so they read as white on dark surfaces.
|
||||||
|
Exclude .invert icons (already inverted for placement on dark backgrounds)
|
||||||
|
so they don't get double-inverted back to black.
|
||||||
|
*/
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme='light']) img[src*='degruyter-icons']:not(.invert) {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] img[src*='degruyter-icons']:not(.invert) {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 7. Base styles ───────────────────────────────────────────────────────── */
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #ffffff;
|
background-color: var(--c-canvas);
|
||||||
color: var(--color-brand-navy);
|
color: var(--c-ink);
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,4 +183,12 @@
|
|||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form controls — always use surface bg and ink text so they theme correctly */
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
background-color: var(--c-surface);
|
||||||
|
color: var(--c-ink);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
|
|||||||
const activeLocale = $derived(getLocale().toUpperCase());
|
const activeLocale = $derived(getLocale().toUpperCase());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative flex min-h-screen flex-col bg-white">
|
<div class="relative flex min-h-screen flex-col bg-canvas">
|
||||||
<!-- DGB purple accent strip -->
|
<!-- DGB purple accent strip -->
|
||||||
<div class="h-1 bg-brand-purple"></div>
|
<div class="h-1 bg-brand-purple"></div>
|
||||||
|
|
||||||
@@ -21,8 +21,8 @@ const activeLocale = $derived(getLocale().toUpperCase());
|
|||||||
onclick={() => setLocale(localeMap[locale])}
|
onclick={() => setLocale(localeMap[locale])}
|
||||||
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
|
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
|
||||||
{activeLocale === locale
|
{activeLocale === locale
|
||||||
? 'font-bold text-brand-navy'
|
? 'font-bold text-ink'
|
||||||
: 'font-normal text-gray-400 hover:text-brand-navy'}"
|
: 'font-normal text-ink-3 hover:text-ink'}"
|
||||||
>
|
>
|
||||||
{locale}
|
{locale}
|
||||||
</button>
|
</button>
|
||||||
@@ -34,15 +34,15 @@ const activeLocale = $derived(getLocale().toUpperCase());
|
|||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="mb-10 text-center">
|
<div class="mb-10 text-center">
|
||||||
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
||||||
<span class="font-sans text-2xl font-bold tracking-widest text-brand-navy uppercase"
|
<span class="font-sans text-2xl font-bold tracking-widest text-ink uppercase"
|
||||||
>Familienarchiv</span
|
>Familienarchiv</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card -->
|
<!-- Card -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-8 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-8 shadow-sm">
|
||||||
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-brand-navy uppercase">
|
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-ink uppercase">
|
||||||
{m.login_heading()}
|
{m.login_heading()}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="username"
|
for="username"
|
||||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
>{m.login_label_username()}</label
|
>{m.login_label_username()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -59,14 +59,14 @@ const activeLocale = $derived(getLocale().toUpperCase());
|
|||||||
id="username"
|
id="username"
|
||||||
required
|
required
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="password"
|
for="password"
|
||||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
>{m.login_label_password()}</label
|
>{m.login_label_password()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -75,7 +75,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
|
|||||||
id="password"
|
id="password"
|
||||||
required
|
required
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="mt-2 w-full bg-brand-navy py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
{m.login_btn_submit()}
|
{m.login_btn_submit()}
|
||||||
</button>
|
</button>
|
||||||
@@ -93,7 +93,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
|
|||||||
<div class="mt-4 text-center">
|
<div class="mt-4 text-center">
|
||||||
<a
|
<a
|
||||||
href="/forgot-password"
|
href="/forgot-password"
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
>{m.login_forgot_password()}</a
|
>{m.login_forgot_password()}</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,6 +104,6 @@ const activeLocale = $derived(getLocale().toUpperCase());
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="py-4 text-center">
|
<div class="py-4 text-center">
|
||||||
<p class="font-sans text-xs tracking-widest text-gray-300 uppercase">Familienarchiv</p>
|
<p class="font-sans text-xs tracking-widest text-ink-3 uppercase">Familienarchiv</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Page from './+page.svelte';
|
|||||||
|
|
||||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
|
||||||
|
|
||||||
// Silence fetch calls from PersonTypeahead when advanced filters are open
|
// Silence fetch calls from PersonTypeahead when advanced filters are open
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
|
|||||||
@@ -26,17 +26,17 @@ function handleSearch() {
|
|||||||
<div class="mx-auto max-w-7xl py-12 sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl py-12 sm:px-6 lg:px-8">
|
||||||
<!-- Header Area -->
|
<!-- Header Area -->
|
||||||
<div
|
<div
|
||||||
class="mb-10 flex flex-col justify-between gap-6 border-b border-brand-navy/10 pb-6 md:flex-row md:items-end"
|
class="mb-10 flex flex-col justify-between gap-6 border-b border-ink/10 pb-6 md:flex-row md:items-end"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="font-serif text-3xl font-medium text-brand-navy">{m.persons_heading()}</h1>
|
<h1 class="font-serif text-3xl font-medium text-ink">{m.persons_heading()}</h1>
|
||||||
<p class="mt-2 max-w-xl font-sans text-sm text-brand-navy/60">
|
<p class="mt-2 max-w-xl font-sans text-sm text-ink/60">
|
||||||
{m.persons_subtitle()}
|
{m.persons_subtitle()}
|
||||||
</p>
|
</p>
|
||||||
{#if data.canWrite}
|
{#if data.canWrite}
|
||||||
<a
|
<a
|
||||||
href="/persons/new"
|
href="/persons/new"
|
||||||
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-ink/60 transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||||
@@ -61,10 +61,10 @@ function handleSearch() {
|
|||||||
oninput={handleSearch}
|
oninput={handleSearch}
|
||||||
onfocus={() => (qFocused = true)}
|
onfocus={() => (qFocused = true)}
|
||||||
onblur={() => (qFocused = false)}
|
onblur={() => (qFocused = false)}
|
||||||
class="block w-full rounded-sm border border-gray-300 bg-white py-2.5 pr-10 pl-4 font-sans text-sm text-brand-navy placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
class="block w-full rounded-sm border border-line bg-surface py-2.5 pr-10 pl-4 font-sans text-sm text-ink placeholder-ink-3 shadow-sm focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400"
|
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-ink-3"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||||
@@ -79,11 +79,9 @@ function handleSearch() {
|
|||||||
|
|
||||||
{#if data.persons.length === 0}
|
{#if data.persons.length === 0}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-brand-sand bg-white py-16 text-center"
|
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-line bg-surface py-16 text-center"
|
||||||
>
|
>
|
||||||
<div
|
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted text-ink">
|
||||||
class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-brand-sand/30 text-brand-navy"
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -91,25 +89,25 @@ function handleSearch() {
|
|||||||
class="h-6 w-6"
|
class="h-6 w-6"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="font-serif text-lg text-brand-navy">{m.persons_empty_heading()}</p>
|
<p class="font-serif text-lg text-ink">{m.persons_empty_heading()}</p>
|
||||||
<p class="mt-1 font-sans text-sm text-gray-500">{m.persons_empty_text()}</p>
|
<p class="mt-1 font-sans text-sm text-ink-2">{m.persons_empty_text()}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
{#each data.persons as person (person.id)}
|
{#each data.persons as person (person.id)}
|
||||||
<a href="/persons/{person.id}" class="group block h-full">
|
<a href="/persons/{person.id}" class="group block h-full">
|
||||||
<div
|
<div
|
||||||
class="relative flex h-full items-center gap-4 overflow-hidden rounded border border-brand-sand bg-white p-6 shadow-sm transition-all duration-200 hover:border-brand-navy hover:shadow-md"
|
class="relative flex h-full items-center gap-4 overflow-hidden rounded border border-line bg-surface p-6 shadow-sm transition-all duration-200 hover:border-primary hover:shadow-md"
|
||||||
>
|
>
|
||||||
<!-- Decorative Accent on Hover -->
|
<!-- Decorative Accent on Hover -->
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 bottom-0 left-0 w-1 bg-brand-navy opacity-0 transition-opacity group-hover:opacity-100"
|
class="absolute top-0 bottom-0 left-0 w-1 bg-primary opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Avatar -->
|
<!-- Avatar -->
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-navy font-serif text-lg text-white transition-colors group-hover:bg-brand-mint group-hover:text-brand-navy"
|
class="flex h-12 w-12 items-center justify-center rounded-full bg-primary font-serif text-lg text-white transition-colors group-hover:bg-accent group-hover:text-ink"
|
||||||
>
|
>
|
||||||
{person.firstName[0]}{person.lastName[0]}
|
{person.firstName[0]}{person.lastName[0]}
|
||||||
</div>
|
</div>
|
||||||
@@ -118,13 +116,13 @@ function handleSearch() {
|
|||||||
<!-- Info -->
|
<!-- Info -->
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p
|
<p
|
||||||
class="truncate font-serif text-base font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
|
class="truncate font-serif text-base font-medium text-ink decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
|
||||||
>
|
>
|
||||||
{person.firstName}
|
{person.firstName}
|
||||||
{person.lastName}
|
{person.lastName}
|
||||||
</p>
|
</p>
|
||||||
{#if person.alias}
|
{#if person.alias}
|
||||||
<p class="mt-0.5 truncate font-sans text-xs text-gray-500">"{person.alias}"</p>
|
<p class="mt-0.5 truncate font-sans text-xs text-ink-2">"{person.alias}"</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ $effect(() => {
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a
|
<a
|
||||||
href="/persons"
|
href="/persons"
|
||||||
class="group inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
class="group inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||||
@@ -111,15 +111,15 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Header / Metadata Card -->
|
<!-- Header / Metadata Card -->
|
||||||
<div class="mb-10 overflow-hidden rounded-sm border border-brand-sand bg-white shadow-sm">
|
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||||
<div class="h-2 w-full bg-brand-navy"></div>
|
<div class="h-2 w-full bg-primary"></div>
|
||||||
|
|
||||||
<div class="p-8 md:p-10">
|
<div class="p-8 md:p-10">
|
||||||
{#if editMode && data.canWrite}
|
{#if editMode && data.canWrite}
|
||||||
<!-- Edit Form -->
|
<!-- Edit Form -->
|
||||||
<form method="POST" action="?/update" use:enhance>
|
<form method="POST" action="?/update" use:enhance>
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<h2 class="border-b border-gray-100 pb-3 font-serif text-xl text-brand-navy">
|
<h2 class="border-b border-line-2 pb-3 font-serif text-xl text-ink">
|
||||||
{m.person_edit_heading()}
|
{m.person_edit_heading()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ $effect(() => {
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="firstName"
|
for="firstName"
|
||||||
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>{m.form_label_first_name()} *</label
|
>{m.form_label_first_name()} *</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -142,13 +142,13 @@ $effect(() => {
|
|||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={person.firstName}
|
value={person.firstName}
|
||||||
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="lastName"
|
for="lastName"
|
||||||
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>{m.form_label_last_name()} *</label
|
>{m.form_label_last_name()} *</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -157,13 +157,13 @@ $effect(() => {
|
|||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={person.lastName}
|
value={person.lastName}
|
||||||
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label
|
<label
|
||||||
for="alias"
|
for="alias"
|
||||||
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>{m.form_label_alias()}</label
|
>{m.form_label_alias()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -171,13 +171,13 @@ $effect(() => {
|
|||||||
name="alias"
|
name="alias"
|
||||||
type="text"
|
type="text"
|
||||||
value={person.alias ?? ''}
|
value={person.alias ?? ''}
|
||||||
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="birthYear"
|
for="birthYear"
|
||||||
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>{m.person_label_birth_year()}</label
|
>{m.person_label_birth_year()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -188,13 +188,13 @@ $effect(() => {
|
|||||||
max="2100"
|
max="2100"
|
||||||
placeholder={m.person_placeholder_year()}
|
placeholder={m.person_placeholder_year()}
|
||||||
value={person.birthYear ?? ''}
|
value={person.birthYear ?? ''}
|
||||||
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="deathYear"
|
for="deathYear"
|
||||||
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>{m.person_label_death_year()}</label
|
>{m.person_label_death_year()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -205,13 +205,13 @@ $effect(() => {
|
|||||||
max="2100"
|
max="2100"
|
||||||
placeholder={m.person_placeholder_year()}
|
placeholder={m.person_placeholder_year()}
|
||||||
value={person.deathYear ?? ''}
|
value={person.deathYear ?? ''}
|
||||||
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label
|
<label
|
||||||
for="notes"
|
for="notes"
|
||||||
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>{m.person_label_notes()}</label
|
>{m.person_label_notes()}</label
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -219,7 +219,7 @@ $effect(() => {
|
|||||||
name="notes"
|
name="notes"
|
||||||
rows="4"
|
rows="4"
|
||||||
placeholder={m.person_placeholder_notes()}
|
placeholder={m.person_placeholder_notes()}
|
||||||
class="block w-full resize-y rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
|
class="block w-full resize-y rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||||
>{person.notes ?? ''}</textarea
|
>{person.notes ?? ''}</textarea
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,14 +228,14 @@ $effect(() => {
|
|||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded bg-brand-navy px-5 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/80"
|
class="rounded bg-primary px-5 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
|
||||||
>
|
>
|
||||||
{m.btn_save()}
|
{m.btn_save()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (editMode = false)}
|
onclick={() => (editMode = false)}
|
||||||
class="rounded border border-gray-300 px-5 py-2 text-sm font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
|
class="rounded border border-line px-5 py-2 text-sm font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
{m.btn_cancel()}
|
{m.btn_cancel()}
|
||||||
</button>
|
</button>
|
||||||
@@ -247,15 +247,15 @@ $effect(() => {
|
|||||||
<div class="flex flex-col items-start gap-8 md:flex-row">
|
<div class="flex flex-col items-start gap-8 md:flex-row">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
class="flex h-24 w-24 items-center justify-center rounded-full border border-brand-sand bg-brand-sand/30 text-brand-navy"
|
class="flex h-24 w-24 items-center justify-center rounded-full border border-line bg-muted text-ink"
|
||||||
>
|
>
|
||||||
<span class="font-serif text-3xl">{person.firstName[0]}{person.lastName[0]}</span>
|
<span class="font-serif text-3xl">{person.firstName[0]}{person.lastName[0]}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full flex-1">
|
<div class="w-full flex-1">
|
||||||
<div class="mb-8 flex items-start justify-between border-b border-gray-100 pb-4">
|
<div class="mb-8 flex items-start justify-between border-b border-line-2 pb-4">
|
||||||
<h1 class="font-serif text-4xl text-brand-navy">
|
<h1 class="font-serif text-4xl text-ink">
|
||||||
{person.firstName}
|
{person.firstName}
|
||||||
{person.lastName}
|
{person.lastName}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -263,7 +263,7 @@ $effect(() => {
|
|||||||
{#if data.canWrite}
|
{#if data.canWrite}
|
||||||
<button
|
<button
|
||||||
onclick={() => (editMode = true)}
|
onclick={() => (editMode = true)}
|
||||||
class="inline-flex items-center gap-1.5 rounded border border-gray-300 px-3 py-1.5 text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:border-brand-navy hover:text-brand-navy"
|
class="inline-flex items-center gap-1.5 rounded border border-line px-3 py-1.5 text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:border-primary hover:text-ink"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg"
|
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg"
|
||||||
@@ -280,10 +280,10 @@ $effect(() => {
|
|||||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>{m.person_label_full_name()}</span
|
>{m.person_label_full_name()}</span
|
||||||
>
|
>
|
||||||
<span class="block font-serif text-lg text-brand-navy"
|
<span class="block font-serif text-lg text-ink"
|
||||||
>{person.firstName} {person.lastName}</span
|
>{person.firstName} {person.lastName}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,23 +291,21 @@ $effect(() => {
|
|||||||
{#if person.alias}
|
{#if person.alias}
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>{m.form_label_alias()}</span
|
>{m.form_label_alias()}</span
|
||||||
>
|
>
|
||||||
<span class="block font-serif text-lg text-brand-navy italic"
|
<span class="block font-serif text-lg text-ink italic">"{person.alias}"</span>
|
||||||
>"{person.alias}"</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if person.birthYear || person.deathYear}
|
{#if person.birthYear || person.deathYear}
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>
|
>
|
||||||
{#if person.birthYear && person.deathYear}{m.person_label_birth_year()} / {m.person_label_death_year()}{:else if person.birthYear}{m.person_label_birth_year()}{:else}{m.person_label_death_year()}{/if}
|
{#if person.birthYear && person.deathYear}{m.person_label_birth_year()} / {m.person_label_death_year()}{:else if person.birthYear}{m.person_label_birth_year()}{:else}{m.person_label_death_year()}{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="block font-serif text-lg text-brand-navy">
|
<span class="block font-serif text-lg text-ink">
|
||||||
{#if person.birthYear}* {person.birthYear}{/if}{#if person.birthYear && person.deathYear}
|
{#if person.birthYear}* {person.birthYear}{/if}{#if person.birthYear && person.deathYear}
|
||||||
{/if}{#if person.deathYear}† {person.deathYear}{/if}
|
{/if}{#if person.deathYear}† {person.deathYear}{/if}
|
||||||
</span>
|
</span>
|
||||||
@@ -317,10 +315,10 @@ $effect(() => {
|
|||||||
{#if person.notes}
|
{#if person.notes}
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>{m.person_label_notes()}</span
|
>{m.person_label_notes()}</span
|
||||||
>
|
>
|
||||||
<p class="font-serif text-base whitespace-pre-wrap text-brand-navy">
|
<p class="font-serif text-base whitespace-pre-wrap text-ink">
|
||||||
{person.notes}
|
{person.notes}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,10 +333,10 @@ $effect(() => {
|
|||||||
<!-- Merge Section -->
|
<!-- Merge Section -->
|
||||||
{#if data.canWrite}
|
{#if data.canWrite}
|
||||||
{#key person.id}
|
{#key person.id}
|
||||||
<div class="mb-10 overflow-hidden rounded-sm border border-brand-sand bg-white shadow-sm">
|
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||||
<div class="p-6 md:p-8">
|
<div class="p-6 md:p-8">
|
||||||
<h2 class="mb-1 font-serif text-lg text-brand-navy">{m.person_merge_heading()}</h2>
|
<h2 class="mb-1 font-serif text-lg text-ink">{m.person_merge_heading()}</h2>
|
||||||
<p class="mb-5 font-sans text-sm text-gray-500">
|
<p class="mb-5 font-sans text-sm text-ink-2">
|
||||||
{m.person_merge_description()}
|
{m.person_merge_description()}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -381,7 +379,7 @@ $effect(() => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (showMergeConfirm = false)}
|
onclick={() => (showMergeConfirm = false)}
|
||||||
class="rounded border border-gray-300 px-4 py-2 text-sm font-bold tracking-widest text-gray-500 uppercase transition-colors hover:bg-gray-50"
|
class="rounded border border-line px-4 py-2 text-sm font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
{m.btn_cancel()}
|
{m.btn_cancel()}
|
||||||
</button>
|
</button>
|
||||||
@@ -406,17 +404,17 @@ $effect(() => {
|
|||||||
<!-- Co-Correspondents Section -->
|
<!-- Co-Correspondents Section -->
|
||||||
{#if coCorrespondents.length > 0}
|
{#if coCorrespondents.length > 0}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h3 class="mb-3 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.person_co_correspondents_heading()}
|
{m.person_co_correspondents_heading()}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each coCorrespondents as c (c.id)}
|
{#each coCorrespondents as c (c.id)}
|
||||||
<a
|
<a
|
||||||
href="/conversations?senderId={person.id}&receiverId={c.id}"
|
href="/conversations?senderId={person.id}&receiverId={c.id}"
|
||||||
class="inline-flex items-center gap-1.5 rounded-full border border-brand-sand px-3 py-1 font-serif text-sm text-brand-navy transition-colors hover:border-brand-navy"
|
class="inline-flex items-center gap-1.5 rounded-full border border-line px-3 py-1 font-serif text-sm text-ink transition-colors hover:border-primary"
|
||||||
>
|
>
|
||||||
{c.name}
|
{c.name}
|
||||||
<span class="font-sans text-xs text-gray-400">({c.count})</span>
|
<span class="font-sans text-xs text-ink-3">({c.count})</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -425,18 +423,18 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- Sent Documents Section -->
|
<!-- Sent Documents Section -->
|
||||||
<div class="mb-10">
|
<div class="mb-10">
|
||||||
<div class="mb-6 flex items-center gap-3 border-b border-brand-navy/10 pb-2">
|
<div class="mb-6 flex items-center gap-3 border-b border-ink/10 pb-2">
|
||||||
<h2 class="font-serif text-xl text-brand-navy">{m.person_docs_heading()}</h2>
|
<h2 class="font-serif text-xl text-ink">{m.person_docs_heading()}</h2>
|
||||||
<span class="rounded-full bg-brand-navy px-2 py-1 text-xs font-bold text-white">
|
<span class="rounded-full bg-primary px-2 py-1 text-xs font-bold text-white">
|
||||||
{sentDocuments.length}
|
{sentDocuments.length}
|
||||||
</span>
|
</span>
|
||||||
{#if sentYearRange}
|
{#if sentYearRange}
|
||||||
<span class="font-sans text-xs text-gray-400">{sentYearRange}</span>
|
<span class="font-sans text-xs text-ink-3">{sentYearRange}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if sentDocuments.length > 1}
|
{#if sentDocuments.length > 1}
|
||||||
<button
|
<button
|
||||||
onclick={() => (sortDirSent = sortDirSent === 'DESC' ? 'ASC' : 'DESC')}
|
onclick={() => (sortDirSent = sortDirSent === 'DESC' ? 'ASC' : 'DESC')}
|
||||||
class="ml-auto text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
|
class="ml-auto text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
{sortDirSent === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
|
{sortDirSent === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
|
||||||
</button>
|
</button>
|
||||||
@@ -444,8 +442,8 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if sentDocuments.length === 0}
|
{#if sentDocuments.length === 0}
|
||||||
<div class="rounded-sm border border-dashed border-brand-sand bg-white p-12 text-center">
|
<div class="rounded-sm border border-dashed border-line bg-surface p-12 text-center">
|
||||||
<p class="font-sans text-gray-500">{m.person_no_docs()}</p>
|
<p class="font-sans text-ink-2">{m.person_no_docs()}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="space-y-3">
|
<ul class="space-y-3">
|
||||||
@@ -453,12 +451,12 @@ $effect(() => {
|
|||||||
<li class="group">
|
<li class="group">
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}"
|
href="/documents/{doc.id}"
|
||||||
class="block border border-brand-sand bg-white p-4 transition-all duration-200 hover:border-brand-navy hover:shadow-md"
|
class="block border border-line bg-surface p-4 transition-all duration-200 hover:border-primary hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4 overflow-hidden">
|
<div class="flex items-center gap-4 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-brand-sand/20 text-brand-navy transition-colors group-hover:bg-brand-mint group-hover:text-brand-navy"
|
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-muted text-ink transition-colors group-hover:bg-accent group-hover:text-ink"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
||||||
@@ -469,16 +467,16 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div
|
<div
|
||||||
class="truncate font-serif text-base font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
|
class="truncate font-serif text-base font-medium text-ink decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
|
||||||
>
|
>
|
||||||
{doc.title || doc.originalFilename}
|
{doc.title || doc.originalFilename}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 flex items-center space-x-2 font-sans text-xs text-gray-500">
|
<div class="mt-0.5 flex items-center space-x-2 font-sans text-xs text-ink-2">
|
||||||
<span
|
<span
|
||||||
>{doc.documentDate ? formatDate(doc.documentDate) : m.doc_no_date()}</span
|
>{doc.documentDate ? formatDate(doc.documentDate) : m.doc_no_date()}</span
|
||||||
>
|
>
|
||||||
{#if doc.location}
|
{#if doc.location}
|
||||||
<span class="text-brand-mint">•</span>
|
<span class="text-accent">•</span>
|
||||||
<span>{doc.location}</span>
|
<span>{doc.location}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -488,7 +486,7 @@ $effect(() => {
|
|||||||
<span
|
<span
|
||||||
class="hidden items-center rounded-full border px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase sm:inline-flex
|
class="hidden items-center rounded-full border px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase sm:inline-flex
|
||||||
{doc.status === 'UPLOADED'
|
{doc.status === 'UPLOADED'
|
||||||
? 'border-brand-mint/50 bg-brand-mint/20 text-brand-navy'
|
? 'border-accent/50 bg-accent/20 text-ink'
|
||||||
: 'border-yellow-200 bg-yellow-50 text-yellow-800'}"
|
: 'border-yellow-200 bg-yellow-50 text-yellow-800'}"
|
||||||
>
|
>
|
||||||
{doc.status}
|
{doc.status}
|
||||||
@@ -508,7 +506,7 @@ $effect(() => {
|
|||||||
{#if sentDocuments.length > DOCS_PREVIEW_LIMIT && !showAllSent}
|
{#if sentDocuments.length > DOCS_PREVIEW_LIMIT && !showAllSent}
|
||||||
<button
|
<button
|
||||||
onclick={() => (showAllSent = true)}
|
onclick={() => (showAllSent = true)}
|
||||||
class="mt-3 text-xs font-bold tracking-widest text-brand-navy/50 uppercase transition-colors hover:text-brand-navy"
|
class="mt-3 text-xs font-bold tracking-widest text-ink/50 uppercase transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
{m.person_show_more({ count: sentDocuments.length - DOCS_PREVIEW_LIMIT })}
|
{m.person_show_more({ count: sentDocuments.length - DOCS_PREVIEW_LIMIT })}
|
||||||
</button>
|
</button>
|
||||||
@@ -518,18 +516,18 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- Received Documents Section -->
|
<!-- Received Documents Section -->
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-6 flex items-center gap-3 border-b border-brand-navy/10 pb-2">
|
<div class="mb-6 flex items-center gap-3 border-b border-ink/10 pb-2">
|
||||||
<h2 class="font-serif text-xl text-brand-navy">{m.person_received_docs_heading()}</h2>
|
<h2 class="font-serif text-xl text-ink">{m.person_received_docs_heading()}</h2>
|
||||||
<span class="rounded-full bg-brand-navy px-2 py-1 text-xs font-bold text-white">
|
<span class="rounded-full bg-primary px-2 py-1 text-xs font-bold text-white">
|
||||||
{receivedDocuments.length}
|
{receivedDocuments.length}
|
||||||
</span>
|
</span>
|
||||||
{#if receivedYearRange}
|
{#if receivedYearRange}
|
||||||
<span class="font-sans text-xs text-gray-400">{receivedYearRange}</span>
|
<span class="font-sans text-xs text-ink-3">{receivedYearRange}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if receivedDocuments.length > 1}
|
{#if receivedDocuments.length > 1}
|
||||||
<button
|
<button
|
||||||
onclick={() => (sortDirReceived = sortDirReceived === 'DESC' ? 'ASC' : 'DESC')}
|
onclick={() => (sortDirReceived = sortDirReceived === 'DESC' ? 'ASC' : 'DESC')}
|
||||||
class="ml-auto text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
|
class="ml-auto text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
{sortDirReceived === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
|
{sortDirReceived === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
|
||||||
</button>
|
</button>
|
||||||
@@ -537,8 +535,8 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if receivedDocuments.length === 0}
|
{#if receivedDocuments.length === 0}
|
||||||
<div class="rounded-sm border border-dashed border-brand-sand bg-white p-12 text-center">
|
<div class="rounded-sm border border-dashed border-line bg-surface p-12 text-center">
|
||||||
<p class="font-sans text-gray-500">{m.person_no_received_docs()}</p>
|
<p class="font-sans text-ink-2">{m.person_no_received_docs()}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="space-y-3">
|
<ul class="space-y-3">
|
||||||
@@ -546,12 +544,12 @@ $effect(() => {
|
|||||||
<li class="group">
|
<li class="group">
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}"
|
href="/documents/{doc.id}"
|
||||||
class="block border border-brand-sand bg-white p-4 transition-all duration-200 hover:border-brand-navy hover:shadow-md"
|
class="block border border-line bg-surface p-4 transition-all duration-200 hover:border-primary hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4 overflow-hidden">
|
<div class="flex items-center gap-4 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-brand-sand/20 text-brand-navy transition-colors group-hover:bg-brand-mint group-hover:text-brand-navy"
|
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-muted text-ink transition-colors group-hover:bg-accent group-hover:text-ink"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
||||||
@@ -562,16 +560,16 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div
|
<div
|
||||||
class="truncate font-serif text-base font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
|
class="truncate font-serif text-base font-medium text-ink decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
|
||||||
>
|
>
|
||||||
{doc.title || doc.originalFilename}
|
{doc.title || doc.originalFilename}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 flex items-center space-x-2 font-sans text-xs text-gray-500">
|
<div class="mt-0.5 flex items-center space-x-2 font-sans text-xs text-ink-2">
|
||||||
<span
|
<span
|
||||||
>{doc.documentDate ? formatDate(doc.documentDate) : m.doc_no_date()}</span
|
>{doc.documentDate ? formatDate(doc.documentDate) : m.doc_no_date()}</span
|
||||||
>
|
>
|
||||||
{#if doc.location}
|
{#if doc.location}
|
||||||
<span class="text-brand-mint">•</span>
|
<span class="text-accent">•</span>
|
||||||
<span>{doc.location}</span>
|
<span>{doc.location}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -581,7 +579,7 @@ $effect(() => {
|
|||||||
<span
|
<span
|
||||||
class="hidden items-center rounded-full border px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase sm:inline-flex
|
class="hidden items-center rounded-full border px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase sm:inline-flex
|
||||||
{doc.status === 'UPLOADED'
|
{doc.status === 'UPLOADED'
|
||||||
? 'border-brand-mint/50 bg-brand-mint/20 text-brand-navy'
|
? 'border-accent/50 bg-accent/20 text-ink'
|
||||||
: 'border-yellow-200 bg-yellow-50 text-yellow-800'}"
|
: 'border-yellow-200 bg-yellow-50 text-yellow-800'}"
|
||||||
>
|
>
|
||||||
{doc.status}
|
{doc.status}
|
||||||
@@ -601,7 +599,7 @@ $effect(() => {
|
|||||||
{#if receivedDocuments.length > DOCS_PREVIEW_LIMIT && !showAllReceived}
|
{#if receivedDocuments.length > DOCS_PREVIEW_LIMIT && !showAllReceived}
|
||||||
<button
|
<button
|
||||||
onclick={() => (showAllReceived = true)}
|
onclick={() => (showAllReceived = true)}
|
||||||
class="mt-3 text-xs font-bold tracking-widest text-brand-navy/50 uppercase transition-colors hover:text-brand-navy"
|
class="mt-3 text-xs font-bold tracking-widest text-ink/50 uppercase transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
{m.person_show_more({ count: receivedDocuments.length - DOCS_PREVIEW_LIMIT })}
|
{m.person_show_more({ count: receivedDocuments.length - DOCS_PREVIEW_LIMIT })}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ let { form } = $props();
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a
|
<a
|
||||||
href="/persons"
|
href="/persons"
|
||||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||||
@@ -25,7 +25,7 @@ let { form } = $props();
|
|||||||
</svg>
|
</svg>
|
||||||
{m.btn_back_to_overview()}
|
{m.btn_back_to_overview()}
|
||||||
</a>
|
</a>
|
||||||
<h1 class="font-serif text-3xl text-brand-navy">{m.persons_new_heading()}</h1>
|
<h1 class="font-serif text-3xl text-ink">{m.persons_new_heading()}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
@@ -33,14 +33,14 @@ let { form } = $props();
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.persons_section_details()}
|
{m.persons_section_details()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label for="firstName" class="mb-1 block text-sm font-medium text-gray-700"
|
<label for="firstName" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.form_label_first_name()} *</label
|
>{m.form_label_first_name()} *</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -48,12 +48,12 @@ let { form } = $props();
|
|||||||
name="firstName"
|
name="firstName"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="lastName" class="mb-1 block text-sm font-medium text-gray-700"
|
<label for="lastName" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.form_label_last_name()} *</label
|
>{m.form_label_last_name()} *</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -61,12 +61,12 @@ let { form } = $props();
|
|||||||
name="lastName"
|
name="lastName"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label for="alias" class="mb-1 block text-sm font-medium text-gray-700"
|
<label for="alias" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.form_label_alias()}</label
|
>{m.form_label_alias()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -74,7 +74,7 @@ let { form } = $props();
|
|||||||
name="alias"
|
name="alias"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={m.form_placeholder_alias()}
|
placeholder={m.form_placeholder_alias()}
|
||||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,17 +82,14 @@ let { form } = $props();
|
|||||||
|
|
||||||
<!-- Save Bar -->
|
<!-- Save Bar -->
|
||||||
<div
|
<div
|
||||||
class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm"
|
class="mt-4 flex items-center justify-between rounded-sm border border-line bg-surface px-6 py-4 shadow-sm"
|
||||||
>
|
>
|
||||||
<a
|
<a href="/persons" class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
|
||||||
href="/persons"
|
|
||||||
class="text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
|
|
||||||
>
|
|
||||||
{m.btn_cancel()}
|
{m.btn_cancel()}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/80"
|
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
|
||||||
>
|
>
|
||||||
{m.btn_create()}
|
{m.btn_create()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function handleBirthDateInput(e: Event) {
|
|||||||
<!-- Back link -->
|
<!-- Back link -->
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||||
@@ -56,12 +56,12 @@ function handleBirthDateInput(e: Event) {
|
|||||||
{m.btn_back_to_overview()}
|
{m.btn_back_to_overview()}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy">{m.profile_heading()}</h1>
|
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">{m.profile_heading()}</h1>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<!-- Personal info card -->
|
<!-- Personal info card -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.profile_section_personal()}
|
{m.profile_section_personal()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ function handleBirthDateInput(e: Event) {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>
|
>
|
||||||
{m.profile_label_first_name()}
|
{m.profile_label_first_name()}
|
||||||
</span>
|
</span>
|
||||||
@@ -88,13 +88,13 @@ function handleBirthDateInput(e: Event) {
|
|||||||
type="text"
|
type="text"
|
||||||
name="firstName"
|
name="firstName"
|
||||||
value={data.user?.firstName ?? ''}
|
value={data.user?.firstName ?? ''}
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>
|
>
|
||||||
{m.profile_label_last_name()}
|
{m.profile_label_last_name()}
|
||||||
</span>
|
</span>
|
||||||
@@ -102,13 +102,13 @@ function handleBirthDateInput(e: Event) {
|
|||||||
type="text"
|
type="text"
|
||||||
name="lastName"
|
name="lastName"
|
||||||
value={data.user?.lastName ?? ''}
|
value={data.user?.lastName ?? ''}
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>
|
>
|
||||||
{m.profile_label_birth_date()}
|
{m.profile_label_birth_date()}
|
||||||
</span>
|
</span>
|
||||||
@@ -117,14 +117,14 @@ function handleBirthDateInput(e: Event) {
|
|||||||
placeholder="TT.MM.JJJJ"
|
placeholder="TT.MM.JJJJ"
|
||||||
value={birthDateDisplay}
|
value={birthDateDisplay}
|
||||||
oninput={handleBirthDateInput}
|
oninput={handleBirthDateInput}
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="birthDate" value={birthDateIso} />
|
<input type="hidden" name="birthDate" value={birthDateIso} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>
|
>
|
||||||
{m.profile_label_email()}
|
{m.profile_label_email()}
|
||||||
</span>
|
</span>
|
||||||
@@ -132,13 +132,13 @@ function handleBirthDateInput(e: Event) {
|
|||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
value={data.user?.email ?? ''}
|
value={data.user?.email ?? ''}
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>
|
>
|
||||||
{m.profile_label_contact()}
|
{m.profile_label_contact()}
|
||||||
</span>
|
</span>
|
||||||
@@ -146,7 +146,7 @@ function handleBirthDateInput(e: Event) {
|
|||||||
name="contact"
|
name="contact"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder={m.profile_contact_placeholder()}
|
placeholder={m.profile_contact_placeholder()}
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
>{data.user?.contact ?? ''}</textarea
|
>{data.user?.contact ?? ''}</textarea
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
@@ -154,7 +154,7 @@ function handleBirthDateInput(e: Event) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="mt-5 rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
{m.btn_save()}
|
{m.btn_save()}
|
||||||
</button>
|
</button>
|
||||||
@@ -162,8 +162,8 @@ function handleBirthDateInput(e: Event) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Password change card -->
|
<!-- Password change card -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.profile_section_password()}
|
{m.profile_section_password()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ function handleBirthDateInput(e: Event) {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>
|
>
|
||||||
{m.profile_label_current_password()}
|
{m.profile_label_current_password()}
|
||||||
</span>
|
</span>
|
||||||
@@ -194,13 +194,13 @@ function handleBirthDateInput(e: Event) {
|
|||||||
type="password"
|
type="password"
|
||||||
name="currentPassword"
|
name="currentPassword"
|
||||||
required
|
required
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>
|
>
|
||||||
{m.profile_label_new_password()}
|
{m.profile_label_new_password()}
|
||||||
</span>
|
</span>
|
||||||
@@ -208,13 +208,13 @@ function handleBirthDateInput(e: Event) {
|
|||||||
type="password"
|
type="password"
|
||||||
name="newPassword"
|
name="newPassword"
|
||||||
required
|
required
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>
|
>
|
||||||
{m.profile_label_new_password_confirm()}
|
{m.profile_label_new_password_confirm()}
|
||||||
</span>
|
</span>
|
||||||
@@ -222,14 +222,14 @@ function handleBirthDateInput(e: Event) {
|
|||||||
type="password"
|
type="password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
required
|
required
|
||||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="mt-5 rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
{m.btn_save()}
|
{m.btn_save()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ let {
|
|||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative flex min-h-screen flex-col bg-white">
|
<div class="relative flex min-h-screen flex-col bg-surface">
|
||||||
<!-- Accent strip -->
|
<!-- Accent strip -->
|
||||||
<div class="h-1 bg-brand-purple"></div>
|
<div class="h-1 bg-brand-purple"></div>
|
||||||
|
|
||||||
@@ -20,15 +20,15 @@ let {
|
|||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="mb-10 text-center">
|
<div class="mb-10 text-center">
|
||||||
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
||||||
<span class="font-sans text-2xl font-bold tracking-widest text-brand-navy uppercase"
|
<span class="font-sans text-2xl font-bold tracking-widest text-ink uppercase"
|
||||||
>Familienarchiv</span
|
>Familienarchiv</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card -->
|
<!-- Card -->
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-8 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-8 shadow-sm">
|
||||||
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-brand-navy uppercase">
|
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-ink uppercase">
|
||||||
{m.reset_password_heading()}
|
{m.reset_password_heading()}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -37,9 +37,7 @@ let {
|
|||||||
<p class="font-sans text-xs text-green-700">{m.reset_password_success()}</p>
|
<p class="font-sans text-xs text-green-700">{m.reset_password_success()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a href="/login" class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
href="/login"
|
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
||||||
>{m.forgot_password_back_to_login()}</a
|
>{m.forgot_password_back_to_login()}</a
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -49,7 +47,7 @@ let {
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="newPassword"
|
for="newPassword"
|
||||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
>{m.reset_password_label()}</label
|
>{m.reset_password_label()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -58,14 +56,14 @@ let {
|
|||||||
id="newPassword"
|
id="newPassword"
|
||||||
required
|
required
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="confirmPassword"
|
for="confirmPassword"
|
||||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
>{m.reset_password_confirm_label()}</label
|
>{m.reset_password_confirm_label()}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -74,7 +72,7 @@ let {
|
|||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
required
|
required
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -88,15 +86,13 @@ let {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="mt-2 w-full bg-brand-navy py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
{m.reset_password_submit()}
|
{m.reset_password_submit()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="mt-4 text-center">
|
<div class="mt-4 text-center">
|
||||||
<a
|
<a href="/login" class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
href="/login"
|
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
||||||
>{m.forgot_password_back_to_login()}</a
|
>{m.forgot_password_back_to_login()}</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,6 +104,6 @@ let {
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="py-4 text-center">
|
<div class="py-4 text-center">
|
||||||
<p class="font-sans text-xs tracking-widest text-gray-300 uppercase">Familienarchiv</p>
|
<p class="font-sans text-xs tracking-widest text-ink-3 uppercase">Familienarchiv</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const initials = $derived.by(() => {
|
|||||||
<!-- Back link -->
|
<!-- Back link -->
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||||
@@ -37,21 +37,21 @@ const initials = $derived.by(() => {
|
|||||||
Zurück
|
Zurück
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy">{m.user_profile_heading()}</h1>
|
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">{m.user_profile_heading()}</h1>
|
||||||
|
|
||||||
<div class="max-w-md">
|
<div class="max-w-md">
|
||||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<!-- Avatar -->
|
<!-- Avatar -->
|
||||||
<div class="mb-5 flex justify-center">
|
<div class="mb-5 flex justify-center">
|
||||||
{#if initials}
|
{#if initials}
|
||||||
<div
|
<div
|
||||||
class="flex h-16 w-16 items-center justify-center rounded-full bg-brand-navy text-white"
|
class="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-white"
|
||||||
>
|
>
|
||||||
<span class="font-serif text-xl font-bold">{initials}</span>
|
<span class="font-serif text-xl font-bold">{initials}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="flex h-16 w-16 items-center justify-center rounded-full bg-brand-navy text-white"
|
class="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-white"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
@@ -72,9 +72,9 @@ const initials = $derived.by(() => {
|
|||||||
|
|
||||||
<!-- Name and username -->
|
<!-- Name and username -->
|
||||||
<div class="mb-5 text-center">
|
<div class="mb-5 text-center">
|
||||||
<h2 class="font-serif text-xl font-bold text-brand-navy">{fullName}</h2>
|
<h2 class="font-serif text-xl font-bold text-ink">{fullName}</h2>
|
||||||
{#if data.profileUser.firstName || data.profileUser.lastName}
|
{#if data.profileUser.firstName || data.profileUser.lastName}
|
||||||
<p class="mt-0.5 font-sans text-sm text-gray-400">@{data.profileUser.username}</p>
|
<p class="mt-0.5 font-sans text-sm text-ink-3">@{data.profileUser.username}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,19 +82,19 @@ const initials = $derived.by(() => {
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
{#if data.profileUser.email}
|
{#if data.profileUser.email}
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
<span class="font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
<span class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>E-Mail</span
|
>E-Mail</span
|
||||||
>
|
>
|
||||||
<span class="font-serif text-sm text-brand-navy">{data.profileUser.email}</span>
|
<span class="font-serif text-sm text-ink">{data.profileUser.email}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.profileUser.contact}
|
{#if data.profileUser.contact}
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
<span class="font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
<span class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>Kontakt</span
|
>Kontakt</span
|
||||||
>
|
>
|
||||||
<span class="font-serif text-sm text-brand-navy">{data.profileUser.contact}</span>
|
<span class="font-serif text-sm text-ink">{data.profileUser.contact}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,4 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
content: ['./src/**/*.{html,js,svelte,ts}']
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
brand: {
|
|
||||||
navy: '#002850', // Header & Hero background
|
|
||||||
mint: '#A6DAD8', // The Comma accent color
|
|
||||||
sand: '#E4E2D7', // Content background
|
|
||||||
white: '#ffffff'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
// Montserrat for UI/Headers, Merriweather for Body text (as established previously)
|
|
||||||
sans: ['Montserrat', 'sans-serif'],
|
|
||||||
serif: ['Merriweather', 'serif']
|
|
||||||
},
|
|
||||||
fontSize: {
|
|
||||||
huge: '4rem' // For the large stats numbers (e.g., "29", "5000+")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: []
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user