Compare commits
26 Commits
3addc72693
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebeb0cf865 | ||
|
|
46eb908ff4 | ||
|
|
616d6ba01c | ||
|
|
154f859efc | ||
|
|
591316aa22 | ||
|
|
89f2106d8b | ||
|
|
33c29fbff3 | ||
|
|
757d0493a0 | ||
|
|
50e637a9f2 | ||
|
|
4bb9393a83 | ||
|
|
ff3ea70826 | ||
|
|
010904d6e1 | ||
|
|
6b5f05bd2b | ||
|
|
cefcdf3072 | ||
|
|
9cacc6079e | ||
|
|
9d6c7b8605 | ||
|
|
c61b08d6de | ||
|
|
56d79c919e | ||
|
|
3318b5f1c6 | ||
|
|
71eaca9495 | ||
|
|
a3a7af123d | ||
|
|
5fd7e41492 | ||
|
|
0387e9f428 | ||
|
|
49f6b0a8c7 | ||
|
|
1b95d9472b | ||
|
|
4f5f8255a1 |
@@ -28,6 +28,10 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Compile Paraglide i18n
|
||||||
|
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +26,9 @@ public class AuthE2EController {
|
|||||||
|
|
||||||
private final PasswordResetTokenRepository tokenRepository;
|
private final PasswordResetTokenRepository tokenRepository;
|
||||||
|
|
||||||
|
// Hidden from the OpenAPI spec — this endpoint must never appear in the generated api.ts
|
||||||
|
// even when the e2e profile is active alongside the dev profile during spec generation.
|
||||||
|
@Operation(hidden = true)
|
||||||
@GetMapping("/reset-token-for-test")
|
@GetMapping("/reset-token-for-test")
|
||||||
public ResponseEntity<String> getResetTokenForTest(@RequestParam String email) {
|
public ResponseEntity<String> getResetTokenForTest(@RequestParam String email) {
|
||||||
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now())
|
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now())
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ import org.springframework.data.domain.Page;
|
|||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@@ -189,4 +192,65 @@ class DocumentRepositoryTest {
|
|||||||
assertThat(result.getTotalElements()).isEqualTo(5);
|
assertThat(result.getTotalElements()).isEqualTo(5);
|
||||||
assertThat(result.getContent()).allMatch(d -> !d.isMetadataComplete());
|
assertThat(result.getContent()).allMatch(d -> !d.isMetadataComplete());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── findSinglePersonCorrespondence — DISTINCT / multi-receiver safety ────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSinglePersonCorrespondence_returnsExactlyOneResult_whenDocumentHasThreeReceiversAndOneMatchesPersonId() {
|
||||||
|
Person sender = personRepository.save(Person.builder()
|
||||||
|
.firstName("Hans").lastName("Müller").build());
|
||||||
|
Person receiver1 = personRepository.save(Person.builder()
|
||||||
|
.firstName("Anna").lastName("Schmidt").build());
|
||||||
|
Person receiver2 = personRepository.save(Person.builder()
|
||||||
|
.firstName("Bertha").lastName("Wagner").build());
|
||||||
|
Person receiver3 = personRepository.save(Person.builder()
|
||||||
|
.firstName("Clara").lastName("Koch").build());
|
||||||
|
|
||||||
|
// Document addressed to all three receivers
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Rundschreiben")
|
||||||
|
.originalFilename("rundschreiben.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.receivers(new HashSet<>(Set.of(receiver1, receiver2, receiver3)))
|
||||||
|
.documentDate(LocalDate.of(1950, 6, 1))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
|
||||||
|
LocalDate from = LocalDate.of(1900, 1, 1);
|
||||||
|
LocalDate to = LocalDate.of(2000, 1, 1);
|
||||||
|
|
||||||
|
// Query for receiver1 — the DISTINCT must collapse the 3 JOIN rows into 1 result
|
||||||
|
List<Document> results = documentRepository.findSinglePersonCorrespondence(
|
||||||
|
receiver1.getId(), from, to, sort);
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
assertThat(results.get(0).getId()).isEqualTo(doc.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSinglePersonCorrespondence_includesDocumentsWherePerson_isSender() {
|
||||||
|
Person sender = personRepository.save(Person.builder()
|
||||||
|
.firstName("Hans").lastName("Müller").build());
|
||||||
|
Person receiver = personRepository.save(Person.builder()
|
||||||
|
.firstName("Anna").lastName("Schmidt").build());
|
||||||
|
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief als Absender")
|
||||||
|
.originalFilename("brief_absender.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.receivers(new HashSet<>(Set.of(receiver)))
|
||||||
|
.documentDate(LocalDate.of(1950, 6, 1))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
|
||||||
|
LocalDate from = LocalDate.of(1900, 1, 1);
|
||||||
|
LocalDate to = LocalDate.of(2000, 1, 1);
|
||||||
|
|
||||||
|
List<Document> results = documentRepository.findSinglePersonCorrespondence(
|
||||||
|
sender.getId(), from, to, sort);
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
855
docs/specs/header-nav-redesign-spec.html
Normal file
855
docs/specs/header-nav-redesign-spec.html
Normal file
@@ -0,0 +1,855 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Header / Navigation Redesign Spec · Familienarchiv</title>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5}
|
||||||
|
.doc{max-width:1440px;margin:0 auto;padding:48px 32px}
|
||||||
|
|
||||||
|
/* ── Masthead ─── */
|
||||||
|
.mast{background:#0D2240;border-radius:10px;padding:32px 40px;margin-bottom:48px}
|
||||||
|
.mast-top{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin-bottom:16px}
|
||||||
|
.mast h1{font-size:22px;font-weight:900;color:#fff;letter-spacing:-.4px;margin-bottom:6px}
|
||||||
|
.mast p{font-size:12px;color:rgba(255,255,255,.5);max-width:620px;line-height:1.7}
|
||||||
|
.mast-badge{font-size:9px;font-weight:800;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.8px;white-space:nowrap;flex-shrink:0;margin-top:4px}
|
||||||
|
.mb-draft{background:#FCD34D;color:#78350F}
|
||||||
|
.decisions{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:20px;border-top:1px solid rgba(255,255,255,.1);padding-top:16px}
|
||||||
|
.dec{background:rgba(255,255,255,.06);border-radius:6px;padding:10px 12px}
|
||||||
|
.dec-label{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.35);margin-bottom:5px}
|
||||||
|
.dec-value{font-size:9.5px;font-weight:700;color:#fff;line-height:1.5}
|
||||||
|
.dec-value s{color:rgba(255,255,255,.3);font-weight:400}
|
||||||
|
|
||||||
|
/* ── Section headings ─── */
|
||||||
|
.sec{margin-bottom:64px}
|
||||||
|
.sec+.sec{border-top:2px dashed #C8C4BE;padding-top:56px}
|
||||||
|
.sec-h{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:20px;display:flex;align-items:center;gap:10px}
|
||||||
|
.sec-h::after{content:'';flex:1;height:1px;background:#D8D4CE}
|
||||||
|
.sec-num{background:#0D2240;color:#fff;font-size:9px;font-weight:900;padding:2px 7px;border-radius:10px}
|
||||||
|
|
||||||
|
/* ── Screen grid ─── */
|
||||||
|
.sg{display:grid;gap:20px;align-items:start}
|
||||||
|
.sg-3{grid-template-columns:1fr 1fr 1fr}
|
||||||
|
.sg-2{grid-template-columns:1fr 1fr}
|
||||||
|
.sg-2a{grid-template-columns:1.3fr 1fr}
|
||||||
|
.sg-mob{grid-template-columns:1fr 220px}
|
||||||
|
.sb{display:flex;flex-direction:column}
|
||||||
|
.sl{font-size:9px;font-weight:800;color:#888;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px}
|
||||||
|
.sz{background:#E8E4DF;color:#666;padding:1px 5px;border-radius:3px;font-size:8px}
|
||||||
|
.state{padding:1px 6px;border-radius:3px;font-size:8px;font-weight:700}
|
||||||
|
.st-bad{background:#FEE2E2;color:#991B1B}
|
||||||
|
.st-good{background:#DCFCE7;color:#166534}
|
||||||
|
.st-warn{background:#FEF3C7;color:#92400E}
|
||||||
|
.sc{font-size:8.5px;color:#888;margin-top:6px;font-style:italic;line-height:1.5}
|
||||||
|
|
||||||
|
/* ── Annotation callouts ─── */
|
||||||
|
.ann{display:inline-block;font-size:7.5px;font-weight:700;color:#C2410C;background:#FFF7ED;border:1px solid #FDBA74;border-radius:3px;padding:1px 5px;white-space:nowrap}
|
||||||
|
.ann-block{background:#FFF7ED;border:1px solid #FDBA74;border-radius:5px;padding:8px 10px;font-size:10px;color:#7C2D12;line-height:1.5;margin-top:10px}
|
||||||
|
.ann-block strong{font-weight:800}
|
||||||
|
.ann-block ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
|
||||||
|
.ann-block ol{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
|
||||||
|
|
||||||
|
/* ── Wireframe Chrome ─── */
|
||||||
|
.wf{background:#fff;border:2px solid #B8B4AE;border-radius:10px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.08)}
|
||||||
|
.wf-bar{height:24px;background:#E8E4DF;border-bottom:1px solid #C8C4BE;display:flex;align-items:center;padding:0 9px;gap:4px}
|
||||||
|
.dot{width:7px;height:7px;border-radius:50%;background:#C8C4BE}
|
||||||
|
.dot.r{background:#F87171}.dot.y{background:#FCD34D}.dot.g{background:#4ADE80}
|
||||||
|
.urlbar{flex:1;height:11px;background:#D8D4CE;border-radius:3px;margin-left:6px;display:flex;align-items:center;padding:0 5px}
|
||||||
|
.urlbar span{font-size:7.5px;color:#888;font-family:monospace}
|
||||||
|
|
||||||
|
/* ── Current header (white/broken) ─── */
|
||||||
|
.H-OLD{height:44px;background:#ffffff;border-bottom:1.5px solid #E5E7EB;display:flex;align-items:center;padding:0 16px;gap:14px;position:relative}
|
||||||
|
.H-OLD-LOGO{font-size:9px;font-weight:900;color:#012851;letter-spacing:1.2px;font-family:'Arial Black',sans-serif}
|
||||||
|
.H-OLD-NAV{display:flex;gap:10px;align-items:center;margin-left:8px}
|
||||||
|
.H-OLD-LINK{font-size:7.5px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#012851;padding:3px 7px;border-radius:4px}
|
||||||
|
.H-OLD-LINK.act{background:rgba(180,185,255,0.15);color:#012851}
|
||||||
|
.H-OLD-R{margin-left:auto;display:flex;gap:7px;align-items:center}
|
||||||
|
.H-OLD-ICO{width:22px;height:22px;background:#F3F4F6;border-radius:4px;border:1px solid #E5E7EB}
|
||||||
|
.H-OLD-AV{width:22px;height:22px;background:#012851;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:900;color:#fff}
|
||||||
|
|
||||||
|
/* ── NEW header atoms ─── */
|
||||||
|
.STRIP{height:4px;background:#B4B9FF} /* brand-purple accent strip */
|
||||||
|
.N{height:42px;background:#012851;display:flex;align-items:center;padding:0 16px;gap:14px;flex-shrink:0}
|
||||||
|
.logo{font-size:9px;font-weight:900;color:#fff;letter-spacing:1.2px;font-family:'Arial Black',sans-serif}
|
||||||
|
.nl{font-size:7.5px;color:rgba(255,255,255,.55);font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding-bottom:2px}
|
||||||
|
.nl:hover{color:rgba(255,255,255,.85)}
|
||||||
|
.nl.on{color:#fff;border-bottom:2px solid #A1DCD8;padding-bottom:2px}
|
||||||
|
.nr{margin-left:auto;display:flex;gap:8px;align-items:center}
|
||||||
|
.nico{width:20px;height:20px;background:rgba(255,255,255,.1);border-radius:4px}
|
||||||
|
.nico-av{width:22px;height:22px;background:#A1DCD8;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:900;color:#012851}
|
||||||
|
.nico-lbl{font-size:7px;color:rgba(255,255,255,.6);font-weight:700;text-transform:uppercase}
|
||||||
|
|
||||||
|
/* ── Page body placeholder ─── */
|
||||||
|
.MAIN{padding:14px 18px;display:flex;flex-direction:column;gap:10px;background:#ECEAE4;min-height:80px}
|
||||||
|
.PH{height:7px;background:#D8D4CE;border-radius:2px;margin-bottom:4px}
|
||||||
|
.w80{width:80%}.w70{width:70%}.w60{width:60%}.w50{width:50%}.w40{width:40%}.w30{width:30%}
|
||||||
|
|
||||||
|
/* ── Mobile chrome ─── */
|
||||||
|
.WF-M{background:#fff;border:2px solid #B8B4AE;border-radius:16px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.08);width:220px}
|
||||||
|
.WF-M-STATUS{height:18px;background:#012851;display:flex;align-items:center;justify-content:space-between;padding:0 10px}
|
||||||
|
.WF-M-TIME{font-size:7px;color:#fff;font-weight:700}
|
||||||
|
.WF-M-ICONS{display:flex;gap:3px}
|
||||||
|
.WF-M-ICON{width:6px;height:6px;background:rgba(255,255,255,.5);border-radius:1px}
|
||||||
|
.N-M{height:42px;display:flex;align-items:center;padding:0 12px;justify-content:space-between}
|
||||||
|
.HAMBURGER{display:flex;flex-direction:column;gap:3px;justify-content:center;width:18px}
|
||||||
|
.HAMBURGER-LINE{height:1.5px;background:rgba(255,255,255,.85);border-radius:1px}
|
||||||
|
|
||||||
|
/* Mobile old (white bg) */
|
||||||
|
.N-M-OLD{height:44px;background:#ffffff;border-bottom:1.5px solid #E5E7EB;display:flex;align-items:center;padding:0 12px;justify-content:space-between}
|
||||||
|
.H-OLD-M-LOGO{font-size:9px;font-weight:900;color:#012851;letter-spacing:1.2px}
|
||||||
|
|
||||||
|
/* Nav drawer */
|
||||||
|
.DRAWER{background:#fff;border-top:1px solid #E5E7EB;padding:10px 0}
|
||||||
|
.DRAWER-LINK{display:flex;align-items:center;padding:8px 14px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#012851;border-left:3px solid transparent}
|
||||||
|
.DRAWER-LINK.on{border-left-color:#A1DCD8;background:#F0EFE9;color:#012851}
|
||||||
|
.DRAWER-LINK.off{color:rgba(1,40,81,.55)}
|
||||||
|
.DRAWER-DIV{height:1px;background:#E5E7EB;margin:6px 14px}
|
||||||
|
.DRAWER-LANG{display:flex;align-items:center;gap:8px;padding:6px 14px}
|
||||||
|
.DRAWER-LANG-BTN{font-size:7.5px;font-weight:700;color:#012851;padding:2px 7px;border-radius:3px;border:1.5px solid #D1D5DB}
|
||||||
|
.DRAWER-LANG-BTN.on{border-color:#012851;background:#012851;color:#fff}
|
||||||
|
|
||||||
|
/* ── Nav state grid ─── */
|
||||||
|
.NSG{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
|
||||||
|
.NS{display:flex;flex-direction:column;gap:6px}
|
||||||
|
.NS-LABEL{font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
|
||||||
|
.NS-DEMO{height:40px;background:#012851;display:flex;align-items:center;padding:0 14px;border-radius:6px}
|
||||||
|
.NS-LINK{font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding-bottom:2px}
|
||||||
|
.NS-INACTIVE{color:rgba(255,255,255,.55)}
|
||||||
|
.NS-HOVER{color:rgba(255,255,255,.85)}
|
||||||
|
.NS-ACTIVE{color:#fff;border-bottom:2px solid #A1DCD8}
|
||||||
|
.NS-FOCUS{color:#fff;outline:2px solid #A1DCD8;outline-offset:3px;border-radius:2px;padding:1px 3px}
|
||||||
|
|
||||||
|
/* ── Contrast badge ─── */
|
||||||
|
.contrast-badge{display:inline-flex;align-items:center;gap:4px;font-size:8px;font-weight:700;padding:2px 7px;border-radius:20px}
|
||||||
|
.cr-fail{background:#FEE2E2;color:#991B1B}
|
||||||
|
.cr-pass{background:#DCFCE7;color:#166534}
|
||||||
|
|
||||||
|
/* ── Login page ─── */
|
||||||
|
.LOGIN-BG{background:#F0EFE9;min-height:120px;display:flex;flex-direction:column;align-items:center;padding-bottom:14px}
|
||||||
|
.LOGIN-HEADER{width:100%;height:4px;background:#B4B9FF}
|
||||||
|
.LOGIN-NAV{width:100%;height:42px;background:#012851;display:flex;align-items:center;justify-content:space-between;padding:0 16px}
|
||||||
|
.LOGIN-CARD{background:#fff;border:1.5px solid #D8D4CE;border-radius:8px;padding:14px 18px;width:200px;margin-top:14px}
|
||||||
|
.LOGIN-CARD-TITLE{font-size:10px;font-weight:900;color:#012851;margin-bottom:10px;letter-spacing:-.2px}
|
||||||
|
.LOGIN-FIELD{margin-bottom:7px}
|
||||||
|
.LOGIN-FIELD-L{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:3px}
|
||||||
|
.LOGIN-FIELD-I{height:26px;border:1.5px solid #D1D5DB;border-radius:3px;background:#fff;width:100%}
|
||||||
|
.LOGIN-BTN{height:28px;background:#012851;border-radius:3px;width:100%;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:800;color:#fff;text-transform:uppercase;letter-spacing:.5px;margin-top:10px}
|
||||||
|
|
||||||
|
/* ── Changelog / decision list ─── */
|
||||||
|
.CHANGES{background:#fff;border:1.5px solid #E0DDD6;border-radius:8px;padding:20px 24px;margin-bottom:40px}
|
||||||
|
.CHANGES h2{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:14px;padding-bottom:10px;border-bottom:1px solid #E8E4DF}
|
||||||
|
.CHANGES-GRID{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||||
|
.C-COL h3{font-size:10px;font-weight:800;color:#444;margin-bottom:8px}
|
||||||
|
.C-COL ul{list-style:none;display:flex;flex-direction:column;gap:5px}
|
||||||
|
.C-COL ul li{font-size:11px;color:#555;padding-left:16px;position:relative;line-height:1.5}
|
||||||
|
.C-COL.new li::before{content:'✦';position:absolute;left:0;color:#012851;font-size:8px}
|
||||||
|
.C-COL.remove li::before{content:'✗';position:absolute;left:0;color:#DC2626}
|
||||||
|
.C-COL.keep li::before{content:'→';position:absolute;left:0;color:#888}
|
||||||
|
|
||||||
|
/* ── Impl notes ─── */
|
||||||
|
.IMPL{background:#0D2240;border-radius:8px;padding:20px 24px;margin-top:48px}
|
||||||
|
.IMPL h2{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.4);margin-bottom:16px;padding-bottom:10px;border-bottom:1px solid rgba(255,255,255,.08)}
|
||||||
|
.IMPL-GRID{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
|
||||||
|
.IMPL-COL h3{font-size:9.5px;font-weight:800;color:rgba(255,255,255,.6);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px}
|
||||||
|
.IMPL-COL ul{list-style:none;display:flex;flex-direction:column;gap:5px}
|
||||||
|
.IMPL-COL ul li{font-size:10.5px;color:rgba(255,255,255,.75);padding-left:14px;position:relative;line-height:1.5}
|
||||||
|
.IMPL-COL ul li::before{content:'›';position:absolute;left:0;color:rgba(255,255,255,.3)}
|
||||||
|
.IMPL-COL code{font-family:monospace;font-size:9.5px;background:rgba(255,255,255,.08);padding:1px 4px;border-radius:3px;color:#A1DCD8}
|
||||||
|
|
||||||
|
/* ── Dark mode simulation ─── */
|
||||||
|
.DK .N{background:#012851} /* same! brand constant */
|
||||||
|
.DK .nl{color:rgba(255,255,255,.55)}
|
||||||
|
.DK .nl.on{color:#fff}
|
||||||
|
.DK-MAIN{background:#1A1A1A;padding:14px 18px;min-height:60px}
|
||||||
|
.DK-PH{height:7px;background:#2A2A2A;border-radius:2px;margin-bottom:4px}
|
||||||
|
|
||||||
|
/* ── Measurement annotation ─── */
|
||||||
|
.MEA{display:flex;align-items:center;gap:4px;font-size:7.5px;font-weight:700;color:#6B7280;margin-top:8px}
|
||||||
|
.MEA-LINE{flex:1;height:1px;border-top:1px dashed #C8C4BE}
|
||||||
|
.MEA-VAL{background:#E8E4DF;padding:1px 6px;border-radius:3px;white-space:nowrap}
|
||||||
|
.token{font-family:monospace;font-size:8.5px;background:#F0EFE9;border:1px solid #D8D4CE;padding:1px 5px;border-radius:3px;color:#012851}
|
||||||
|
|
||||||
|
/* ── Color swatch ─── */
|
||||||
|
.SW{display:flex;flex-direction:column;align-items:flex-start;gap:3px}
|
||||||
|
.SW-BOX{width:36px;height:20px;border-radius:3px;border:1px solid rgba(0,0,0,.1)}
|
||||||
|
.SW-NAME{font-size:7.5px;font-weight:700;color:#444}
|
||||||
|
.SW-HEX{font-size:7px;color:#888;font-family:monospace}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
MASTHEAD
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="mast">
|
||||||
|
<div class="mast-top">
|
||||||
|
<div>
|
||||||
|
<h1>Header / Navigation Redesign</h1>
|
||||||
|
<p>Full header redesign: brand-navy bar, 4px purple accent strip, always-visible logo on mobile, high-contrast nav states, dark-mode as brand constant, and integrated login header. Replaces the current white <code style="font-family:monospace;font-size:10px;background:rgba(255,255,255,.08);padding:1px 4px;border-radius:3px;color:#A1DCD8">bg-surface</code> header that leaks the semantic surface color into what should be a brand-constant element.</p>
|
||||||
|
</div>
|
||||||
|
<span class="mast-badge mb-draft">Draft · 2026-03-30</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:8.5px;color:rgba(255,255,255,.3);margin-bottom:12px">Leonie Voss · Senior UX Designer</div>
|
||||||
|
<div class="decisions">
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Header background</div>
|
||||||
|
<div class="dec-value"><s>bg-surface (#fff)</s><br>→ brand-navy #012851</div>
|
||||||
|
</div>
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Top accent strip</div>
|
||||||
|
<div class="dec-value"><s>None</s><br>→ 4px · brand-purple #B4B9FF</div>
|
||||||
|
</div>
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Active nav state</div>
|
||||||
|
<div class="dec-value"><s>rgba purple pill (~1.08:1)</s><br>→ white + mint underline</div>
|
||||||
|
</div>
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Mobile logo</div>
|
||||||
|
<div class="dec-value"><s>Hidden</s><br>→ Always visible, left side</div>
|
||||||
|
</div>
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Dark mode header</div>
|
||||||
|
<div class="dec-value"><s>Flips to #1a1a1a</s><br>→ Stays brand-navy (constant)</div>
|
||||||
|
</div>
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Login page header</div>
|
||||||
|
<div class="dec-value"><s>Hidden entirely</s><br>→ Brand header, logo-only</div>
|
||||||
|
</div>
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Language switcher (login)</div>
|
||||||
|
<div class="dec-value"><s>Floating, no context</s><br>→ Integrated in login header right</div>
|
||||||
|
</div>
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Total header height</div>
|
||||||
|
<div class="dec-value">4px strip + 64px bar<br>= 68px total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
CHANGES SUMMARY
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="CHANGES">
|
||||||
|
<h2>What changes vs. current implementation</h2>
|
||||||
|
<div class="CHANGES-GRID">
|
||||||
|
<div class="C-COL new">
|
||||||
|
<h3>New / changed</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Header <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">bg-surface</code> → fixed <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">bg-brand-navy</code> (#012851) — not theme-aware</li>
|
||||||
|
<li>4px accent strip above header: <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">background: #B4B9FF</code></li>
|
||||||
|
<li>Nav link colors on navy: inactive 55% white, hover 85% white, active 100% white</li>
|
||||||
|
<li>Active indicator: 2px bottom border in brand-mint (#A1DCD8) instead of rgba purple pill</li>
|
||||||
|
<li>Mobile: logo always visible left; hamburger icon white (was hidden or missing)</li>
|
||||||
|
<li>User avatar: mint background (#A1DCD8) with navy text (#012851)</li>
|
||||||
|
<li>Dark mode: <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">dark:bg-surface</code> override removed from header — stays navy</li>
|
||||||
|
<li>Login page: <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">isAuthPage</code> guard changed — shows logo-only header, not <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">null</code></li>
|
||||||
|
<li>Language switcher on login: moved into header right slot</li>
|
||||||
|
<li>Mobile drawer: opens below navy header, white background, navy text links, mint active indicator</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="C-COL keep">
|
||||||
|
<h3>Kept unchanged</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">AppNav</code> component structure — only CSS changes</li>
|
||||||
|
<li>Sticky header behavior (<code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">sticky top-0 z-50</code>)</li>
|
||||||
|
<li>Max-width container and horizontal padding</li>
|
||||||
|
<li>NotificationBell, ThemeToggle, LanguageSwitcher components — only icon color changes</li>
|
||||||
|
<li>UserMenu component — only avatar color changes</li>
|
||||||
|
<li>Mobile drawer open/close logic</li>
|
||||||
|
<li>Admin nav link conditional visibility</li>
|
||||||
|
<li><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">isAuthPage</code> derived value — still used, just different output</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
SECTION 1 — CURRENT STATE
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sec-h"><span class="sec-num">1</span> Current state — problems annotated</div>
|
||||||
|
|
||||||
|
<div class="sg sg-2a">
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Desktop <span class="sz">≥768px</span> <span class="state st-bad">6 issues</span></div>
|
||||||
|
<div class="wf">
|
||||||
|
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>/dokumente</span></div></div>
|
||||||
|
<!-- Current broken header -->
|
||||||
|
<div class="H-OLD">
|
||||||
|
<!-- issue 1: white bg, no strip -->
|
||||||
|
<div style="position:absolute;top:0;left:0;right:0;height:2px;background:#FDBA74;opacity:.3"></div>
|
||||||
|
<span class="H-OLD-LOGO">FAMILIENARCHIV</span>
|
||||||
|
<div class="H-OLD-NAV">
|
||||||
|
<span class="H-OLD-LINK act">Dokumente</span>
|
||||||
|
<span class="H-OLD-LINK">Personen</span>
|
||||||
|
<span class="H-OLD-LINK">Korrespondenz</span>
|
||||||
|
</div>
|
||||||
|
<div class="H-OLD-R">
|
||||||
|
<span style="font-size:7px;color:#6B7280;font-weight:700">DE</span>
|
||||||
|
<div class="H-OLD-ICO"></div>
|
||||||
|
<div class="H-OLD-ICO"></div>
|
||||||
|
<div class="H-OLD-AV">LV</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="MAIN"><div class="PH w80"></div><div class="PH w60"></div><div class="PH w70"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ann-block">
|
||||||
|
<strong>Issues — desktop</strong>
|
||||||
|
<ol>
|
||||||
|
<li><strong>①</strong> Background is <code style="font-size:9px;background:#FFF0E8;padding:1px 3px;border-radius:2px">bg-surface</code> (white) — not brand. Every other archival app in this family uses a dark branded header.</li>
|
||||||
|
<li><strong>②</strong> No 4px accent strip at top — missing the canonical brand-purple cap.</li>
|
||||||
|
<li><strong>③</strong> Active link "Dokumente" uses <code style="font-size:9px;background:#FFF0E8;padding:1px 3px;border-radius:2px">rgba(180,185,255,0.15)</code> on white = contrast ~1.08:1. Completely invisible. WCAG AA minimum is 3:1 for UI components.</li>
|
||||||
|
<li><strong>④</strong> Logo is navy-on-white — works in light mode but will disappear in dark mode if header ever inherits <code style="font-size:9px;background:#FFF0E8;padding:1px 3px;border-radius:2px">#1a1a1a</code>.</li>
|
||||||
|
<li><strong>⑤</strong> Dark mode: header flips to near-black (#1a1a1a) — breaks brand consistency. Header should be a brand constant, not a semantic surface.</li>
|
||||||
|
<li><strong>⑥</strong> User avatar: dark navy circle blends with any dark-mode context and provides no semantic meaning via color.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Mobile <span class="sz">375px</span> <span class="state st-bad">logo missing</span></div>
|
||||||
|
<div style="display:flex;justify-content:center">
|
||||||
|
<div class="WF-M">
|
||||||
|
<div class="WF-M-STATUS"><span class="WF-M-TIME">09:41</span><div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div></div>
|
||||||
|
<div class="N-M-OLD">
|
||||||
|
<!-- No logo! Just hamburger. -->
|
||||||
|
<div style="width:18px;display:flex;flex-direction:column;gap:3px">
|
||||||
|
<div style="height:1.5px;background:#012851;border-radius:1px"></div>
|
||||||
|
<div style="height:1.5px;background:#012851;border-radius:1px"></div>
|
||||||
|
<div style="height:1.5px;background:#012851;border-radius:1px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="H-OLD-ICO" style="width:20px;height:20px"></div>
|
||||||
|
<div class="H-OLD-AV">LV</div>
|
||||||
|
</div>
|
||||||
|
<div class="MAIN" style="padding:10px 12px;min-height:60px">
|
||||||
|
<div class="PH w80"></div><div class="PH w60"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ann-block" style="margin-top:8px">
|
||||||
|
<strong>Mobile issues</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Logo hidden on mobile — brand identity completely lost</li>
|
||||||
|
<li>Hamburger icon is dark on white — fine in light mode, breaks in dark</li>
|
||||||
|
<li>Header still white — same surface-color problem as desktop</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
SECTION 2 — PROPOSED DESKTOP
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sec-h"><span class="sec-num">2</span> Proposed redesign — Desktop</div>
|
||||||
|
|
||||||
|
<div class="sg sg-2" style="margin-bottom:24px">
|
||||||
|
|
||||||
|
<!-- Light mode -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Light mode <span class="sz">≥768px</span> <span class="state st-good">Proposed</span></div>
|
||||||
|
<div class="wf">
|
||||||
|
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>/korrespondenz</span></div></div>
|
||||||
|
<div class="STRIP"></div>
|
||||||
|
<div class="N">
|
||||||
|
<span class="logo">FAMILIENARCHIV</span>
|
||||||
|
<div style="width:1px;height:16px;background:rgba(255,255,255,.15);margin:0 2px"></div>
|
||||||
|
<span class="nl">Dokumente</span>
|
||||||
|
<span class="nl">Personen</span>
|
||||||
|
<span class="nl on">Korrespondenz</span>
|
||||||
|
<div class="nr">
|
||||||
|
<span class="nico-lbl">DE</span>
|
||||||
|
<div class="nico" style="background:rgba(255,255,255,.12)"></div><!-- theme toggle -->
|
||||||
|
<div class="nico" style="background:rgba(255,255,255,.12)"></div><!-- bell -->
|
||||||
|
<div class="nico-av">LV</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="MAIN"><div class="PH w80"></div><div class="PH w60"></div><div class="PH w70"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="MEA">
|
||||||
|
<div class="MEA-LINE"></div>
|
||||||
|
<span class="MEA-VAL">4px accent strip · background: #B4B9FF</span>
|
||||||
|
<div class="MEA-LINE"></div>
|
||||||
|
</div>
|
||||||
|
<div class="MEA">
|
||||||
|
<div class="MEA-LINE"></div>
|
||||||
|
<span class="MEA-VAL">64px nav bar · background: #012851</span>
|
||||||
|
<div class="MEA-LINE"></div>
|
||||||
|
</div>
|
||||||
|
<div class="MEA">
|
||||||
|
<div class="MEA-LINE"></div>
|
||||||
|
<span class="MEA-VAL">Total: 68px</span>
|
||||||
|
<div class="MEA-LINE"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Active link: "Korrespondenz" — white text + 2px bottom border in #A1DCD8. Divider between logo and nav: rgba(255,255,255,0.15). Avatar: mint bg + navy text.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dark mode -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Dark mode <span class="sz">≥768px</span> <span class="state st-good">Same navy — brand constant</span></div>
|
||||||
|
<div class="wf" style="border-color:#444">
|
||||||
|
<div class="wf-bar" style="background:#333;border-color:#444"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar" style="background:#444"><span style="color:#aaa">/korrespondenz</span></div></div>
|
||||||
|
<div class="STRIP"></div><!-- strip is identical — not theme-aware -->
|
||||||
|
<div class="N"><!-- N stays the same #012851 in dark mode too -->
|
||||||
|
<span class="logo">FAMILIENARCHIV</span>
|
||||||
|
<div style="width:1px;height:16px;background:rgba(255,255,255,.15);margin:0 2px"></div>
|
||||||
|
<span class="nl">Dokumente</span>
|
||||||
|
<span class="nl">Personen</span>
|
||||||
|
<span class="nl on">Korrespondenz</span>
|
||||||
|
<div class="nr">
|
||||||
|
<span class="nico-lbl">DE</span>
|
||||||
|
<div class="nico"></div>
|
||||||
|
<div class="nico"></div>
|
||||||
|
<div class="nico-av">LV</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="DK-MAIN"><div class="DK-PH w80"></div><div class="DK-PH w60"></div><div class="DK-PH w70"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ann-block" style="background:#EFF6FF;border-color:#BFDBFE;color:#1E40AF;margin-top:8px">
|
||||||
|
<strong>Dark mode rule:</strong> The header is a brand element, not a semantic surface. It does NOT respond to the <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">dark:</code> variant. Page content behind it switches; the header stays #012851.
|
||||||
|
<ul style="margin-top:4px">
|
||||||
|
<li>Remove <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">dark:bg-surface</code> from header element</li>
|
||||||
|
<li>Apply <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">bg-brand-navy</code> as a non-dark-variant class</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Element key -->
|
||||||
|
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:8px;padding:16px 20px;margin-top:8px">
|
||||||
|
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:12px">Element color tokens</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:16px">
|
||||||
|
<div class="SW">
|
||||||
|
<div class="SW-BOX" style="background:#012851"></div>
|
||||||
|
<div class="SW-NAME">brand-navy</div>
|
||||||
|
<div class="SW-HEX">#012851</div>
|
||||||
|
<div style="font-size:7px;color:#888;margin-top:2px">Header bg</div>
|
||||||
|
</div>
|
||||||
|
<div class="SW">
|
||||||
|
<div class="SW-BOX" style="background:#B4B9FF"></div>
|
||||||
|
<div class="SW-NAME">brand-purple</div>
|
||||||
|
<div class="SW-HEX">#B4B9FF</div>
|
||||||
|
<div style="font-size:7px;color:#888;margin-top:2px">Accent strip</div>
|
||||||
|
</div>
|
||||||
|
<div class="SW">
|
||||||
|
<div class="SW-BOX" style="background:#A1DCD8"></div>
|
||||||
|
<div class="SW-NAME">brand-mint</div>
|
||||||
|
<div class="SW-HEX">#A1DCD8</div>
|
||||||
|
<div style="font-size:7px;color:#888;margin-top:2px">Active underline · avatar bg</div>
|
||||||
|
</div>
|
||||||
|
<div class="SW">
|
||||||
|
<div class="SW-BOX" style="background:#ffffff;border-color:#D0D0D0"></div>
|
||||||
|
<div class="SW-NAME">white</div>
|
||||||
|
<div class="SW-HEX">#ffffff</div>
|
||||||
|
<div style="font-size:7px;color:#888;margin-top:2px">Active link text · logo</div>
|
||||||
|
</div>
|
||||||
|
<div class="SW">
|
||||||
|
<div class="SW-BOX" style="background:rgba(255,255,255,0.55)"></div>
|
||||||
|
<div class="SW-NAME">white/55</div>
|
||||||
|
<div class="SW-HEX">rgba(255,255,255,.55)</div>
|
||||||
|
<div style="font-size:7px;color:#888;margin-top:2px">Inactive nav links</div>
|
||||||
|
</div>
|
||||||
|
<div class="SW">
|
||||||
|
<div class="SW-BOX" style="background:rgba(255,255,255,0.85)"></div>
|
||||||
|
<div class="SW-NAME">white/85</div>
|
||||||
|
<div class="SW-HEX">rgba(255,255,255,.85)</div>
|
||||||
|
<div style="font-size:7px;color:#888;margin-top:2px">Hover nav links</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
SECTION 3 — NAV STATES
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sec-h"><span class="sec-num">3</span> Nav link states</div>
|
||||||
|
|
||||||
|
<div class="NSG" style="margin-bottom:20px">
|
||||||
|
|
||||||
|
<!-- Inactive -->
|
||||||
|
<div class="NS">
|
||||||
|
<div class="NS-LABEL">Inactive</div>
|
||||||
|
<div class="NS-DEMO">
|
||||||
|
<span class="NS-LINK NS-INACTIVE">Personen</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px;font-size:9px;color:#555;line-height:1.6">
|
||||||
|
<div><span class="token">color: rgba(255,255,255,.55)</span></div>
|
||||||
|
<div style="margin-top:4px;display:flex;align-items:center;gap:6px">
|
||||||
|
<span class="contrast-badge cr-pass">4.9:1 ✓ AA</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Intentionally muted — communicates "not here yet" without removing affordance.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hover -->
|
||||||
|
<div class="NS">
|
||||||
|
<div class="NS-LABEL">Hover</div>
|
||||||
|
<div class="NS-DEMO">
|
||||||
|
<span class="NS-LINK NS-HOVER">Personen</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px;font-size:9px;color:#555;line-height:1.6">
|
||||||
|
<div><span class="token">color: rgba(255,255,255,.85)</span></div>
|
||||||
|
<div style="margin-top:4px;display:flex;align-items:center;gap:6px">
|
||||||
|
<span class="contrast-badge cr-pass">7.8:1 ✓ AA</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Smooth brightness step on hover. Transition: <code style="font-size:9px">color 150ms ease</code>.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active -->
|
||||||
|
<div class="NS">
|
||||||
|
<div class="NS-LABEL">Active (current page)</div>
|
||||||
|
<div class="NS-DEMO">
|
||||||
|
<span class="NS-LINK NS-ACTIVE">Korrespondenz</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px;font-size:9px;color:#555;line-height:1.6">
|
||||||
|
<div><span class="token">color: #ffffff</span></div>
|
||||||
|
<div><span class="token">border-bottom: 2px solid #A1DCD8</span></div>
|
||||||
|
<div style="margin-top:4px;display:flex;align-items:center;gap:6px">
|
||||||
|
<span class="contrast-badge cr-pass">21:1 ✓ AAA</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Mint underline is the active indicator — not a background pill. Clear, low-weight, distinct from hover.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Focus -->
|
||||||
|
<div class="NS">
|
||||||
|
<div class="NS-LABEL">Focus (keyboard)</div>
|
||||||
|
<div class="NS-DEMO">
|
||||||
|
<span class="NS-LINK NS-FOCUS">Personen</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px;font-size:9px;color:#555;line-height:1.6">
|
||||||
|
<div><span class="token">outline: 2px solid #A1DCD8</span></div>
|
||||||
|
<div><span class="token">outline-offset: 3px</span></div>
|
||||||
|
<div><span class="token">border-radius: 2px</span></div>
|
||||||
|
<div style="margin-top:4px;display:flex;align-items:center;gap:6px">
|
||||||
|
<span class="contrast-badge cr-pass">3.4:1 ✓ AA</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Mint outline on navy — meets WCAG 3:1 focus indicator requirement. Never suppress outline.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Before/After contrast comparison -->
|
||||||
|
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:8px;padding:16px 20px">
|
||||||
|
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:12px">Active state contrast — before vs. after</div>
|
||||||
|
<div class="sg sg-2">
|
||||||
|
<div>
|
||||||
|
<div class="sl" style="margin-bottom:8px">Before <span class="state st-bad">Fails WCAG</span></div>
|
||||||
|
<div style="background:#ffffff;border-radius:5px;padding:10px 14px;border:1.5px solid #E5E7EB;display:inline-flex;align-items:center;gap:0">
|
||||||
|
<span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#012851;background:rgba(180,185,255,0.15);padding:4px 10px;border-radius:4px">Dokumente</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px;font-size:9px;color:#555">
|
||||||
|
Navy text (#012851) on rgba(180,185,255,0.15) on white.<br>
|
||||||
|
Effective background: approx. #F4F4FF.<br>
|
||||||
|
<span class="contrast-badge cr-fail" style="margin-top:4px">~1.08:1 ✗ Fail</span>
|
||||||
|
</div>
|
||||||
|
<div class="sc">The active pill is invisible. Users can't tell which page they're on.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="sl" style="margin-bottom:8px">After <span class="state st-good">Passes WCAG AAA</span></div>
|
||||||
|
<div style="background:#012851;border-radius:5px;padding:10px 14px;display:inline-flex;align-items:center">
|
||||||
|
<span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#fff;border-bottom:2px solid #A1DCD8;padding-bottom:2px">Dokumente</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px;font-size:9px;color:#555">
|
||||||
|
White text (#ffffff) on navy (#012851).<br>
|
||||||
|
Mint underline: #A1DCD8 on navy = 3.1:1 for the indicator itself.<br>
|
||||||
|
<span class="contrast-badge cr-pass" style="margin-top:4px">21:1 ✓ AAA (text)</span>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Unambiguous. The underline echoes brand-mint used elsewhere as an accent.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
SECTION 4 — MOBILE
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sec-h"><span class="sec-num">4</span> Mobile header + nav drawer</div>
|
||||||
|
|
||||||
|
<div class="sg sg-3" style="align-items:start">
|
||||||
|
|
||||||
|
<!-- Current mobile (broken) -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Current <span class="state st-bad">No logo</span></div>
|
||||||
|
<div style="display:flex;justify-content:center">
|
||||||
|
<div class="WF-M">
|
||||||
|
<div class="WF-M-STATUS"><span class="WF-M-TIME">09:41</span><div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div></div>
|
||||||
|
<div class="N-M-OLD">
|
||||||
|
<div style="width:18px;display:flex;flex-direction:column;gap:3px">
|
||||||
|
<div style="height:1.5px;background:#012851;border-radius:1px;width:100%"></div>
|
||||||
|
<div style="height:1.5px;background:#012851;border-radius:1px;width:100%"></div>
|
||||||
|
<div style="height:1.5px;background:#012851;border-radius:1px;width:100%"></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-left:auto;display:flex;gap:5px;align-items:center">
|
||||||
|
<div class="H-OLD-ICO" style="width:18px;height:18px"></div>
|
||||||
|
<div class="H-OLD-AV" style="width:20px;height:20px">LV</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="MAIN" style="padding:10px 12px;min-height:60px"><div class="PH w80"></div><div class="PH w60"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ann-block" style="margin-top:8px">
|
||||||
|
<strong>Problem:</strong> No logo. The user has zero brand context. On first load, there is no visual cue that this is Familienarchiv. The hamburger icon color (dark navy) will also break in dark mode.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Proposed mobile header -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Proposed header <span class="state st-good">Logo visible</span></div>
|
||||||
|
<div style="display:flex;justify-content:center">
|
||||||
|
<div class="WF-M">
|
||||||
|
<div class="WF-M-STATUS"><span class="WF-M-TIME">09:41</span><div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div></div>
|
||||||
|
<div style="height:3px;background:#B4B9FF"></div><!-- accent strip, thinner on mobile -->
|
||||||
|
<div class="N-M" style="background:#012851">
|
||||||
|
<span class="logo" style="font-size:7.5px;letter-spacing:1px">FAMILIENARCHIV</span>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<div class="nico-av" style="width:20px;height:20px;font-size:5.5px">LV</div>
|
||||||
|
<div class="HAMBURGER">
|
||||||
|
<div class="HAMBURGER-LINE"></div>
|
||||||
|
<div class="HAMBURGER-LINE"></div>
|
||||||
|
<div class="HAMBURGER-LINE"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="MAIN" style="padding:10px 12px;min-height:60px"><div class="PH w80"></div><div class="PH w60"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Logo always visible left. Avatar + hamburger right. Accent strip is 3px on mobile (saves 1px). Background is brand-navy — no theme variation.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Proposed drawer open -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Nav drawer <span class="state st-good">Open state</span></div>
|
||||||
|
<div style="display:flex;justify-content:center">
|
||||||
|
<div class="WF-M">
|
||||||
|
<div class="WF-M-STATUS"><span class="WF-M-TIME">09:41</span><div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div></div>
|
||||||
|
<div style="height:3px;background:#B4B9FF"></div>
|
||||||
|
<div class="N-M" style="background:#012851">
|
||||||
|
<span class="logo" style="font-size:7.5px;letter-spacing:1px">FAMILIENARCHIV</span>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<div class="nico-av" style="width:20px;height:20px;font-size:5.5px">LV</div>
|
||||||
|
<!-- X icon when drawer open -->
|
||||||
|
<div style="width:18px;height:18px;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,.85);font-size:13px;font-weight:300">✕</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Drawer -->
|
||||||
|
<div class="DRAWER">
|
||||||
|
<div class="DRAWER-LINK off">Dokumente</div>
|
||||||
|
<div class="DRAWER-LINK off">Personen</div>
|
||||||
|
<div class="DRAWER-LINK on">Korrespondenz</div>
|
||||||
|
<div class="DRAWER-LINK off" style="font-size:7px;color:rgba(1,40,81,.4)">Admin</div>
|
||||||
|
<div class="DRAWER-DIV"></div>
|
||||||
|
<div class="DRAWER-LANG">
|
||||||
|
<span style="font-size:7px;color:#888;font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin-right:4px">Sprache</span>
|
||||||
|
<div class="DRAWER-LANG-BTN on">DE</div>
|
||||||
|
<div class="DRAWER-LANG-BTN">EN</div>
|
||||||
|
<div class="DRAWER-LANG-BTN">ES</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="MAIN" style="padding:10px 12px;min-height:40px;opacity:.4"><div class="PH w80"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Drawer uses white background with navy text — intentional reversal of the dark header. Active page: mint left border + sand background. Language switcher lives in drawer on mobile (not floating).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
SECTION 5 — LOGIN PAGE
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sec-h"><span class="sec-num">5</span> Login page — branded header</div>
|
||||||
|
|
||||||
|
<div class="sg sg-2">
|
||||||
|
|
||||||
|
<!-- Current login (no header) -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Current — header hidden <span class="state st-bad">No brand context</span></div>
|
||||||
|
<div class="wf">
|
||||||
|
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>/login</span></div></div>
|
||||||
|
<!-- Floating language switcher (no context) -->
|
||||||
|
<div style="position:relative;min-height:140px;background:#F0EFE9">
|
||||||
|
<div style="position:absolute;top:8px;right:10px;display:flex;gap:4px">
|
||||||
|
<span style="font-size:7.5px;font-weight:700;color:#012851;background:#fff;border:1.5px solid #D1D5DB;padding:2px 7px;border-radius:3px">DE</span>
|
||||||
|
<span style="font-size:7.5px;font-weight:700;color:#888;padding:2px 7px;border-radius:3px">EN</span>
|
||||||
|
<span style="font-size:7.5px;font-weight:700;color:#888;padding:2px 7px;border-radius:3px">ES</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;padding-top:22px">
|
||||||
|
<div class="LOGIN-CARD">
|
||||||
|
<div class="LOGIN-CARD-TITLE">Anmelden</div>
|
||||||
|
<div class="LOGIN-FIELD"><div class="LOGIN-FIELD-L">E-Mail</div><div class="LOGIN-FIELD-I"></div></div>
|
||||||
|
<div class="LOGIN-FIELD"><div class="LOGIN-FIELD-L">Passwort</div><div class="LOGIN-FIELD-I"></div></div>
|
||||||
|
<div class="LOGIN-BTN">Anmelden</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ann-block">
|
||||||
|
<strong>Problems:</strong> Header is hidden entirely on auth pages. Language switcher floats top-right with no visual anchor — it's a ghost. Users arrive with zero brand context. The page could be any app.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Proposed login (branded header) -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Proposed — logo-only header <span class="state st-good">Branded</span></div>
|
||||||
|
<div class="wf">
|
||||||
|
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>/login</span></div></div>
|
||||||
|
<div class="LOGIN-BG">
|
||||||
|
<div class="LOGIN-HEADER"></div><!-- 4px purple strip -->
|
||||||
|
<div class="LOGIN-NAV">
|
||||||
|
<span class="logo">FAMILIENARCHIV</span>
|
||||||
|
<!-- Right: language switcher integrated in header -->
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<span style="font-size:7.5px;font-weight:800;color:#fff;opacity:.9">DE</span>
|
||||||
|
<span style="font-size:7.5px;font-weight:700;color:rgba(255,255,255,.5)">EN</span>
|
||||||
|
<span style="font-size:7.5px;font-weight:700;color:rgba(255,255,255,.5)">ES</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="LOGIN-CARD">
|
||||||
|
<div class="LOGIN-CARD-TITLE">Anmelden</div>
|
||||||
|
<div class="LOGIN-FIELD"><div class="LOGIN-FIELD-L">E-Mail</div><div class="LOGIN-FIELD-I"></div></div>
|
||||||
|
<div class="LOGIN-FIELD"><div class="LOGIN-FIELD-L">Passwort</div><div class="LOGIN-FIELD-I"></div></div>
|
||||||
|
<div class="LOGIN-BTN">Anmelden</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Accent strip + navy header appears on login. No nav links (user is not authenticated). Language switcher lives in header right slot — same position as desktop, consistent muscle memory. The brand is present from the first moment the user sees the app.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Code change note -->
|
||||||
|
<div class="ann-block" style="background:#EFF6FF;border-color:#BFDBFE;color:#1E40AF;margin-top:16px">
|
||||||
|
<strong>Implementation change:</strong> In <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">+layout.svelte</code>, the <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">{#if !isAuthPage}</code> guard currently hides the entire header. Replace with a conditional that renders a <em>login variant</em> of the header (logo + lang switcher, no nav links) when <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">isAuthPage</code> is true. Move the <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">LanguageSwitcher</code> import into the header for the auth variant. Remove the floating <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">LanguageSwitcher</code> from <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">/login/+page.svelte</code>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
SECTION 6 — RIGHT UTILITIES DETAIL
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sec-h"><span class="sec-num">6</span> Right utility area — element by element</div>
|
||||||
|
|
||||||
|
<div style="background:#012851;border-radius:8px;padding:16px 20px;margin-bottom:16px">
|
||||||
|
<div style="height:40px;display:flex;align-items:center;gap:10px;justify-content:flex-end">
|
||||||
|
<!-- Language switcher -->
|
||||||
|
<div style="display:flex;gap:5px;align-items:center;border-right:1px solid rgba(255,255,255,.15);padding-right:10px">
|
||||||
|
<span style="font-size:8px;font-weight:800;color:#fff">DE</span>
|
||||||
|
<span style="font-size:8px;font-weight:700;color:rgba(255,255,255,.5)">EN</span>
|
||||||
|
<span style="font-size:8px;font-weight:700;color:rgba(255,255,255,.5)">ES</span>
|
||||||
|
</div>
|
||||||
|
<!-- Theme toggle -->
|
||||||
|
<div style="width:24px;height:24px;background:rgba(255,255,255,.1);border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:12px;opacity:.7">☀</div>
|
||||||
|
<!-- Notification bell -->
|
||||||
|
<div style="position:relative;width:24px;height:24px;display:flex;align-items:center;justify-content:center">
|
||||||
|
<span style="font-size:14px;color:rgba(255,255,255,.75)">🔔</span>
|
||||||
|
<div style="position:absolute;top:3px;right:2px;width:7px;height:7px;background:#EF4444;border-radius:50%;border:1.5px solid #012851"></div>
|
||||||
|
</div>
|
||||||
|
<!-- User avatar -->
|
||||||
|
<div class="nico-av">LV</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px">
|
||||||
|
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:12px">
|
||||||
|
<div style="font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:8px">Language switcher</div>
|
||||||
|
<div style="font-size:9px;color:#444;line-height:1.6">
|
||||||
|
Active lang: <span class="token">color: #ffffff</span><br>
|
||||||
|
Inactive lang: <span class="token">color: rgba(255,255,255,.5)</span><br>
|
||||||
|
Separator from rest: <span class="token">border-right: 1px solid rgba(255,255,255,.15)</span><br>
|
||||||
|
On login: visible in header right slot
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:12px">
|
||||||
|
<div style="font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:8px">Theme toggle</div>
|
||||||
|
<div style="font-size:9px;color:#444;line-height:1.6">
|
||||||
|
Icon: white at <span class="token">opacity: 0.7</span><br>
|
||||||
|
Hover: <span class="token">opacity: 1.0</span><br>
|
||||||
|
Background: <span class="token">rgba(255,255,255,.1)</span><br>
|
||||||
|
No change to toggle logic — icon color only
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:12px">
|
||||||
|
<div style="font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:8px">Notification bell</div>
|
||||||
|
<div style="font-size:9px;color:#444;line-height:1.6">
|
||||||
|
Icon: white at <span class="token">opacity: 0.75</span><br>
|
||||||
|
Badge: stays <span class="token">bg-red-500</span> (#EF4444)<br>
|
||||||
|
Badge border: <span class="token">border: 1.5px solid #012851</span> (halos on navy)<br>
|
||||||
|
No component logic changes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:12px">
|
||||||
|
<div style="font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:8px">User avatar</div>
|
||||||
|
<div style="font-size:9px;color:#444;line-height:1.6">
|
||||||
|
Background: <span class="token">#A1DCD8</span> (brand-mint)<br>
|
||||||
|
Text: <span class="token">#012851</span> (brand-navy)<br>
|
||||||
|
Contrast: 4.8:1 ✓ AA<br>
|
||||||
|
Replaces navy bg (dark-on-dark in dark mode)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
IMPLEMENTATION NOTES
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="IMPL">
|
||||||
|
<h2>Implementation notes</h2>
|
||||||
|
<div class="IMPL-GRID">
|
||||||
|
<div class="IMPL-COL">
|
||||||
|
<h3>CSS / Tailwind changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Header: replace <code>bg-surface</code> with <code>bg-[#012851]</code> (or add a <code>bg-brand-navy</code> utility to <code>layout.css</code>)</li>
|
||||||
|
<li>Remove <code>border-b border-line-2</code> from header — the accent strip replaces the visual separator</li>
|
||||||
|
<li>Add a <code><div class="h-1 bg-[#B4B9FF]"></code> before the nav bar in <code>+layout.svelte</code></li>
|
||||||
|
<li>Nav links: replace <code>text-ink</code> + <code>bg-nav-active</code> with opacity-based white utilities: <code>text-white/55</code> inactive, <code>hover:text-white/85</code>, <code>text-white border-b-2 border-[#A1DCD8]</code> active</li>
|
||||||
|
<li>User avatar: swap <code>bg-brand-navy text-white</code> → <code>bg-[#A1DCD8] text-[#012851]</code></li>
|
||||||
|
<li>Notification badge: add <code>border-2 border-[#012851]</code> to badge element</li>
|
||||||
|
<li>Dark mode: on the <code><header></code> element, ensure there is NO <code>dark:</code> variant overriding the background</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="IMPL-COL">
|
||||||
|
<h3>Component changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>+layout.svelte</code>: split the <code>{#if !isAuthPage}</code> guard into two branches — full header (authed) vs. login header (logo + lang only)</li>
|
||||||
|
<li><code>AppNav.svelte</code>: ensure logo is always rendered, not hidden on mobile via <code>hidden sm:flex</code> or similar</li>
|
||||||
|
<li><code>AppNav.svelte</code>: hamburger button — icon color from dark to <code>text-white/85</code></li>
|
||||||
|
<li><code>AppNav.svelte</code>: active link class — remove <code>bg-nav-active</code>, add bottom border in mint</li>
|
||||||
|
<li><code>UserMenu.svelte</code>: avatar background and text color</li>
|
||||||
|
<li><code>ThemeToggle.svelte</code>: icon fill/stroke → <code>text-white/70</code></li>
|
||||||
|
<li><code>NotificationBell.svelte</code>: icon color → <code>text-white/75</code></li>
|
||||||
|
<li><code>/login/+page.svelte</code>: remove standalone <code><LanguageSwitcher></code> — it moves to the layout header</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="IMPL-COL">
|
||||||
|
<h3>CSS variable candidates</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Consider adding to <code>layout.css</code>:<br><code>--header-bg: #012851;</code><br><code>--header-accent: #B4B9FF;</code><br><code>--header-nav-active: #A1DCD8;</code></li>
|
||||||
|
<li>These are intentionally NOT in the dark-mode <code>@media (prefers-color-scheme: dark)</code> block — they are brand constants</li>
|
||||||
|
<li>If Tailwind 4 theme is configured, add:<br><code>brand-navy: #012851</code><br><code>brand-purple: #B4B9FF</code><br><code>brand-mint: #A1DCD8</code><br>to the <code>@theme</code> block in <code>layout.css</code></li>
|
||||||
|
<li>No backend changes required</li>
|
||||||
|
<li>No i18n key changes required</li>
|
||||||
|
<li>No new routes required</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
127
frontend/e2e/korrespondenz.spec.ts
Normal file
127
frontend/e2e/korrespondenz.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
|
||||||
|
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||||
|
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Korrespondenz – empty state', () => {
|
||||||
|
test('shows the search heading when no person is selected', async ({ page }) => {
|
||||||
|
await page.goto('/korrespondenz');
|
||||||
|
await expect(page.getByText(/Korrespondenz durchsuchen/i)).toBeVisible();
|
||||||
|
const a11y = await buildAxe(page).analyze();
|
||||||
|
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/korrespondenz-empty.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nav link goes to /korrespondenz', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// Click the nav link (desktop text or mobile icon)
|
||||||
|
const navLink = page.getByRole('link', { name: /Korrespondenz/i }).first();
|
||||||
|
await navLink.click();
|
||||||
|
await expect(page).toHaveURL(/\/korrespondenz/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Korrespondenz – single-person mode', () => {
|
||||||
|
test('shows hint bar and documents when navigated with senderId', async ({ page }) => {
|
||||||
|
// Get a real person ID from the persons list
|
||||||
|
await page.goto('/persons');
|
||||||
|
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
|
||||||
|
await firstPersonLink.click();
|
||||||
|
await page.waitForURL(/\/persons\/.+/);
|
||||||
|
|
||||||
|
// Extract the person ID from the URL
|
||||||
|
const personId = page.url().split('/persons/')[1].split('?')[0];
|
||||||
|
|
||||||
|
// Navigate to korrespondenz in single-person mode
|
||||||
|
await page.goto(`/korrespondenz?senderId=${personId}`);
|
||||||
|
|
||||||
|
// Hint bar should be visible
|
||||||
|
await expect(page.getByText(/Alle Briefe von/i)).toBeVisible();
|
||||||
|
|
||||||
|
// Filter controls should be active (not dimmed)
|
||||||
|
const filterStrip = page.locator('[aria-disabled="false"]').first();
|
||||||
|
await expect(filterStrip).toBeAttached();
|
||||||
|
|
||||||
|
const a11y = await buildAxe(page).analyze();
|
||||||
|
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/korrespondenz-single-person.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sort toggle changes URL direction param', async ({ page }) => {
|
||||||
|
await page.goto('/persons');
|
||||||
|
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
|
||||||
|
await firstPersonLink.click();
|
||||||
|
await page.waitForURL(/\/persons\/.+/);
|
||||||
|
const personId = page.url().split('/persons/')[1].split('?')[0];
|
||||||
|
|
||||||
|
await page.goto(`/korrespondenz?senderId=${personId}&dir=DESC`);
|
||||||
|
await page.getByTestId('conv-sort-btn').click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/dir=ASC/);
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/korrespondenz-sort-asc.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Korrespondenz – bilateral mode', () => {
|
||||||
|
test('shows asymmetry bar when both persons have shared documents', async ({ page }) => {
|
||||||
|
// Navigate to a person then follow a co-correspondent suggestion if available
|
||||||
|
await page.goto('/persons');
|
||||||
|
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
|
||||||
|
await firstPersonLink.click();
|
||||||
|
await page.waitForURL(/\/persons\/.+/);
|
||||||
|
const senderId = page.url().split('/persons/')[1].split('?')[0];
|
||||||
|
|
||||||
|
// Try to find a co-correspondent link from the person detail page
|
||||||
|
const corrLink = page
|
||||||
|
.locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]')
|
||||||
|
.first();
|
||||||
|
if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||||
|
await corrLink.click();
|
||||||
|
await page.waitForURL(/\/korrespondenz\?.*receiverId=/);
|
||||||
|
|
||||||
|
// Hint bar should NOT be shown in bilateral mode
|
||||||
|
await expect(page.getByText(/Alle Briefe von/i)).not.toBeVisible();
|
||||||
|
|
||||||
|
const a11y = await buildAxe(page).analyze();
|
||||||
|
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/korrespondenz-bilateral.png' });
|
||||||
|
} else {
|
||||||
|
// E2E seed must include bilateral correspondents — a missing link is a test failure.
|
||||||
|
throw new Error(
|
||||||
|
`No bilateral correspondent links found for person ${senderId}. Ensure the E2E seed contains at least one bilateral correspondence pair.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('swap button swaps sender and receiver in URL', async ({ page }) => {
|
||||||
|
await page.goto('/persons');
|
||||||
|
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
|
||||||
|
await firstPersonLink.click();
|
||||||
|
await page.waitForURL(/\/persons\/.+/);
|
||||||
|
const senderId = page.url().split('/persons/')[1].split('?')[0];
|
||||||
|
|
||||||
|
const corrLink = page
|
||||||
|
.locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]')
|
||||||
|
.first();
|
||||||
|
if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||||
|
const href = await corrLink.getAttribute('href');
|
||||||
|
await corrLink.click();
|
||||||
|
await page.waitForURL(/\/korrespondenz\?.*receiverId=/);
|
||||||
|
|
||||||
|
// Extract original receiverId from the href
|
||||||
|
const url = new URL(href!, 'http://x');
|
||||||
|
const originalReceiverId = url.searchParams.get('receiverId')!;
|
||||||
|
|
||||||
|
// Click swap
|
||||||
|
await page.getByTestId('conv-swap-btn').click();
|
||||||
|
|
||||||
|
// After swap the former receiver is now senderId
|
||||||
|
await expect(page).toHaveURL(new RegExp(`senderId=${originalReceiverId}`));
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/korrespondenz-swapped.png' });
|
||||||
|
} else {
|
||||||
|
test.skip(true, `No bilateral correspondent links found for person ${senderId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
79
frontend/src/lib/components/DateInput.svelte
Normal file
79
frontend/src/lib/components/DateInput.svelte
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { isoToGerman, handleGermanDateInput, germanToIso } from '$lib/utils/date';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
class?: string;
|
||||||
|
onchange?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
errorMessage = $bindable<string | null>(null),
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
placeholder,
|
||||||
|
class: className = '',
|
||||||
|
onchange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let display = $state(isoToGerman(value ?? ''));
|
||||||
|
|
||||||
|
// ─── Validation helper ────────────────────────────────────────────────────
|
||||||
|
function isCalendarValid(iso: string): boolean {
|
||||||
|
if (!iso) return false;
|
||||||
|
const [, mm, dd] = iso.match(/^\d{4}-(\d{2})-(\d{2})$/) ?? [];
|
||||||
|
const month = parseInt(mm, 10);
|
||||||
|
const day = parseInt(dd, 10);
|
||||||
|
return month >= 1 && month <= 12 && day >= 1 && day <= 31;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Input handler ────────────────────────────────────────────────────────
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const result = handleGermanDateInput(e);
|
||||||
|
display = result.display;
|
||||||
|
|
||||||
|
if (result.display === '') {
|
||||||
|
value = '';
|
||||||
|
errorMessage = null;
|
||||||
|
onchange?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.display.length < 10) {
|
||||||
|
value = '';
|
||||||
|
errorMessage = m.form_date_error();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iso = germanToIso(result.display);
|
||||||
|
if (!iso || !isCalendarValid(iso)) {
|
||||||
|
value = '';
|
||||||
|
errorMessage = m.form_date_error();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = iso;
|
||||||
|
errorMessage = null;
|
||||||
|
onchange?.();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="10"
|
||||||
|
id={id}
|
||||||
|
value={display}
|
||||||
|
placeholder={placeholder ?? m.form_placeholder_date()}
|
||||||
|
oninput={handleInput}
|
||||||
|
class={className}
|
||||||
|
/>
|
||||||
|
{#if name}
|
||||||
|
<input type="hidden" name={name} value={value} />
|
||||||
|
{/if}
|
||||||
210
frontend/src/lib/components/DateInput.svelte.spec.ts
Normal file
210
frontend/src/lib/components/DateInput.svelte.spec.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { describe, expect, it, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import DateInput from './DateInput.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DateInput – rendering', () => {
|
||||||
|
it('renders a text input with inputmode=numeric and maxlength=10', async () => {
|
||||||
|
render(DateInput, {});
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await expect.element(input).toBeInTheDocument();
|
||||||
|
await expect.element(input).toHaveAttribute('inputmode', 'numeric');
|
||||||
|
await expect.element(input).toHaveAttribute('maxlength', '10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has default placeholder from paraglide', async () => {
|
||||||
|
render(DateInput, {});
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await expect.element(input).toHaveAttribute('placeholder', 'TT.MM.JJJJ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a custom placeholder', async () => {
|
||||||
|
render(DateInput, { placeholder: 'Geburtsdatum' });
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await expect.element(input).toHaveAttribute('placeholder', 'Geburtsdatum');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes id prop to the input', async () => {
|
||||||
|
render(DateInput, { id: 'my-date' });
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await expect.element(input).toHaveAttribute('id', 'my-date');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Init from value ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DateInput – init from value', () => {
|
||||||
|
it('displays ISO value in German format on mount', async () => {
|
||||||
|
render(DateInput, { value: '2024-12-20' });
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await expect.element(input).toHaveValue('20.12.2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts empty and error-free when no value is given', async () => {
|
||||||
|
let errorMessage: string | null = null;
|
||||||
|
render(DateInput, {
|
||||||
|
get errorMessage() {
|
||||||
|
return errorMessage;
|
||||||
|
},
|
||||||
|
set errorMessage(v) {
|
||||||
|
errorMessage = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await expect.element(input).toHaveValue('');
|
||||||
|
expect(errorMessage).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Typing valid date ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DateInput – typing a valid date', () => {
|
||||||
|
it('auto-formats to DD.MM.YYYY and updates value to ISO', async () => {
|
||||||
|
let value = '';
|
||||||
|
let errorMessage: string | null = null;
|
||||||
|
render(DateInput, {
|
||||||
|
get value() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
set value(v) {
|
||||||
|
value = v;
|
||||||
|
},
|
||||||
|
get errorMessage() {
|
||||||
|
return errorMessage;
|
||||||
|
},
|
||||||
|
set errorMessage(v) {
|
||||||
|
errorMessage = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await input.fill('20122024');
|
||||||
|
await expect.element(input).toHaveValue('20.12.2024');
|
||||||
|
expect(value).toBe('2024-12-20');
|
||||||
|
expect(errorMessage).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Typing invalid month ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DateInput – typing a date with invalid month', () => {
|
||||||
|
it('sets errorMessage and clears value when month > 12', async () => {
|
||||||
|
let value = '';
|
||||||
|
let errorMessage: string | null = null;
|
||||||
|
render(DateInput, {
|
||||||
|
get value() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
set value(v) {
|
||||||
|
value = v;
|
||||||
|
},
|
||||||
|
get errorMessage() {
|
||||||
|
return errorMessage;
|
||||||
|
},
|
||||||
|
set errorMessage(v) {
|
||||||
|
errorMessage = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await input.fill('22222222');
|
||||||
|
await expect.element(input).toHaveValue('22.22.2222');
|
||||||
|
expect(value).toBe('');
|
||||||
|
expect(errorMessage).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Typing partial date ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DateInput – typing a partial date', () => {
|
||||||
|
it('sets errorMessage and clears value when date is incomplete', async () => {
|
||||||
|
let value = '';
|
||||||
|
let errorMessage: string | null = null;
|
||||||
|
render(DateInput, {
|
||||||
|
get value() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
set value(v) {
|
||||||
|
value = v;
|
||||||
|
},
|
||||||
|
get errorMessage() {
|
||||||
|
return errorMessage;
|
||||||
|
},
|
||||||
|
set errorMessage(v) {
|
||||||
|
errorMessage = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await input.fill('2212');
|
||||||
|
await expect.element(input).toHaveValue('22.12');
|
||||||
|
expect(value).toBe('');
|
||||||
|
expect(errorMessage).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Clearing date ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DateInput – clearing the date', () => {
|
||||||
|
it('resets value and errorMessage to null when cleared', async () => {
|
||||||
|
let value = '';
|
||||||
|
let errorMessage: string | null = null;
|
||||||
|
render(DateInput, {
|
||||||
|
get value() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
set value(v) {
|
||||||
|
value = v;
|
||||||
|
},
|
||||||
|
get errorMessage() {
|
||||||
|
return errorMessage;
|
||||||
|
},
|
||||||
|
set errorMessage(v) {
|
||||||
|
errorMessage = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
// Type a valid date first
|
||||||
|
await input.fill('20122024');
|
||||||
|
expect(value).toBe('2024-12-20');
|
||||||
|
// Now clear
|
||||||
|
await input.fill('');
|
||||||
|
expect(value).toBe('');
|
||||||
|
expect(errorMessage).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onchange when the field is cleared', async () => {
|
||||||
|
let called = 0;
|
||||||
|
render(DateInput, { value: '2024-12-20', onchange: () => called++ });
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await input.fill('');
|
||||||
|
expect(called).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Hidden input ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DateInput – hidden input for form submission', () => {
|
||||||
|
it('renders a hidden input with the given name when name prop is set', async () => {
|
||||||
|
render(DateInput, { name: 'documentDate' });
|
||||||
|
const hidden = document.querySelector('input[type="hidden"][name="documentDate"]');
|
||||||
|
expect(hidden).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render a hidden input when name prop is absent', async () => {
|
||||||
|
render(DateInput, {});
|
||||||
|
const hidden = document.querySelector('input[type="hidden"]');
|
||||||
|
expect(hidden).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hidden input value reflects the ISO value', async () => {
|
||||||
|
render(DateInput, { name: 'documentDate', value: '' });
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await input.fill('20122024');
|
||||||
|
const hidden = document.querySelector<HTMLInputElement>(
|
||||||
|
'input[type="hidden"][name="documentDate"]'
|
||||||
|
);
|
||||||
|
await expect.poll(() => hidden?.value).toBe('2024-12-20');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,8 +10,11 @@ interface Props {
|
|||||||
value?: string;
|
value?: string;
|
||||||
initialName?: string;
|
initialName?: string;
|
||||||
suggestedName?: string;
|
suggestedName?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
compact?: boolean;
|
||||||
restrictToCorrespondentsOf?: string;
|
restrictToCorrespondentsOf?: string;
|
||||||
onchange?: (value: string) => void;
|
onchange?: (value: string) => void;
|
||||||
|
onfocused?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -20,12 +23,23 @@ let {
|
|||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
initialName = '',
|
initialName = '',
|
||||||
suggestedName = '',
|
suggestedName = '',
|
||||||
|
placeholder,
|
||||||
|
compact = false,
|
||||||
restrictToCorrespondentsOf,
|
restrictToCorrespondentsOf,
|
||||||
onchange
|
onchange,
|
||||||
|
onfocused
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
// searchTerm must be both prop-derived AND locally writable (user typing), so $state +
|
||||||
|
// $effect is the correct pattern here — writable $derived is read-only and won't work.
|
||||||
|
// eslint-disable-next-line svelte/prefer-writable-derived
|
||||||
let searchTerm = $state(initialName);
|
let searchTerm = $state(initialName);
|
||||||
|
|
||||||
|
// Sync display text when the selected person changes externally (e.g. swap, navigation).
|
||||||
|
$effect(() => {
|
||||||
|
searchTerm = initialName;
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const suggested = suggestedName;
|
const suggested = suggestedName;
|
||||||
if (suggested && !untrack(() => value)) {
|
if (suggested && !untrack(() => value)) {
|
||||||
@@ -79,6 +93,7 @@ function handleInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
|
onfocused?.();
|
||||||
showDropdown = true;
|
showDropdown = true;
|
||||||
if (restrictToCorrespondentsOf) {
|
if (restrictToCorrespondentsOf) {
|
||||||
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
||||||
@@ -120,7 +135,13 @@ 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-ink-2">{label}</label>
|
<label
|
||||||
|
for={name}
|
||||||
|
class={compact
|
||||||
|
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||||
|
: '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} />
|
||||||
|
|
||||||
@@ -131,8 +152,10 @@ function clickOutside(node: HTMLElement) {
|
|||||||
bind:value={searchTerm}
|
bind:value={searchTerm}
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onfocus={handleFocus}
|
onfocus={handleFocus}
|
||||||
placeholder={m.comp_typeahead_placeholder()}
|
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
||||||
class="mt-1 block w-full rounded-md border border-line p-2 shadow-sm focus:border-accent focus:ring-accent"
|
class={compact
|
||||||
|
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:outline-none'
|
||||||
|
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:border-accent focus:ring-accent'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if showDropdown && (results.length > 0 || loading)}
|
{#if showDropdown && (results.length > 0 || loading)}
|
||||||
|
|||||||
@@ -724,22 +724,8 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/auth/reset-token-for-test": {
|
// "/api/auth/reset-token-for-test" removed — @Operation(hidden=true) on AuthE2EController.
|
||||||
parameters: {
|
// Regenerate with `npm run generate:api` after the next backend build to keep in sync.
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get: operations["getResetTokenForTest"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/admin/import-status": {
|
"/api/admin/import-status": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2514,28 +2500,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
getResetTokenForTest: {
|
// getResetTokenForTest removed — @Operation(hidden=true) on AuthE2EController.
|
||||||
parameters: {
|
|
||||||
query: {
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"*/*": string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
importStatus: {
|
importStatus: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* Extra message functions for i18n keys added after the last paraglide compile.
|
|
||||||
*
|
|
||||||
* TODO: Remove this file once the root-owned paraglide files in src/lib/paraglide/
|
|
||||||
* are regenerated (run `npm run dev` or the paraglide compile step as the owning user).
|
|
||||||
* At that point, these functions will be generated into _index.js and the components
|
|
||||||
* that import from here should switch back to importing from $lib/paraglide/messages.js.
|
|
||||||
*
|
|
||||||
* Note: these fall back to German only — locale switching is handled by the generated
|
|
||||||
* paraglide files, not this shim.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Svelte auto-escapes interpolated values — do not use {@html} with these strings.
|
|
||||||
|
|
||||||
export const conv_hint_single_person = (inputs: { name: string }) =>
|
|
||||||
`Alle Briefe von ${inputs.name} — wähle einen Korrespondenten oben um einzugrenzen`;
|
|
||||||
|
|
||||||
export const conv_hint_single_person_filtered = (inputs: {
|
|
||||||
name: string;
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
sortLabel: string;
|
|
||||||
}) => `Alle Briefe von ${inputs.name} · ${inputs.from}–${inputs.to} · ${inputs.sortLabel}`;
|
|
||||||
|
|
||||||
export const conv_strip_period = () => 'Zeitraum';
|
|
||||||
export const conv_strip_from_placeholder = () => 'Von…';
|
|
||||||
export const conv_strip_to_placeholder = () => 'Bis…';
|
|
||||||
export const conv_strip_all_correspondents = () => 'Alle Korrespondenten';
|
|
||||||
export const conv_strip_sort_newest = () => 'Neueste';
|
|
||||||
export const conv_strip_sort_oldest = () => 'Älteste';
|
|
||||||
export const conv_suggestions_heading = () => 'Häufigste Korrespondenten';
|
|
||||||
export const conv_suggestions_all_label = (inputs: { name: string }) =>
|
|
||||||
`Alle Korrespondenten von ${inputs.name}`;
|
|
||||||
export const conv_letters_count = (inputs: { count: number }) => `${inputs.count} Briefe`;
|
|
||||||
export const conv_empty_search_placeholder = () => 'Person suchen…';
|
|
||||||
export const conv_empty_recent_label = () => 'Zuletzt geöffnet';
|
|
||||||
export const conv_no_party = () => '—';
|
|
||||||
109
frontend/src/lib/utils/date.spec.ts
Normal file
109
frontend/src/lib/utils/date.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatGermanDateInput, isoToGerman, germanToIso } from './date';
|
||||||
|
|
||||||
|
// ─── isoToGerman ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('isoToGerman', () => {
|
||||||
|
it('converts a valid ISO date to DD.MM.YYYY', () => {
|
||||||
|
expect(isoToGerman('2024-12-20')).toBe('20.12.2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for empty input', () => {
|
||||||
|
expect(isoToGerman('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for invalid format', () => {
|
||||||
|
expect(isoToGerman('not-a-date')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── germanToIso ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('germanToIso', () => {
|
||||||
|
it('converts DD.MM.YYYY to ISO', () => {
|
||||||
|
expect(germanToIso('20.12.2024')).toBe('2024-12-20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for partial input', () => {
|
||||||
|
expect(germanToIso('20.12')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for empty input', () => {
|
||||||
|
expect(germanToIso('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── formatGermanDateInput ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('formatGermanDateInput – digit stream (no dots typed)', () => {
|
||||||
|
it('leaves 1–2 digits as-is', () => {
|
||||||
|
expect(formatGermanDateInput('2')).toBe('2');
|
||||||
|
expect(formatGermanDateInput('20')).toBe('20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-inserts dot after 2 digits for 3–4 digit input', () => {
|
||||||
|
expect(formatGermanDateInput('201')).toBe('20.1');
|
||||||
|
expect(formatGermanDateInput('2012')).toBe('20.12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-inserts two dots for 5–8 digit input', () => {
|
||||||
|
expect(formatGermanDateInput('20121')).toBe('20.12.1');
|
||||||
|
expect(formatGermanDateInput('20122024')).toBe('20.12.2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores digits beyond 8', () => {
|
||||||
|
expect(formatGermanDateInput('201220249')).toBe('20.12.2024');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatGermanDateInput – manual dot entry with padding', () => {
|
||||||
|
it('pads single-digit day to 2 digits when dot is typed after it', () => {
|
||||||
|
expect(formatGermanDateInput('3.')).toBe('03.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pad a 2-digit day', () => {
|
||||||
|
expect(formatGermanDateInput('03.')).toBe('03.');
|
||||||
|
expect(formatGermanDateInput('20.')).toBe('20.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pads single-digit month to 2 digits when dot is typed after it', () => {
|
||||||
|
expect(formatGermanDateInput('03.3.')).toBe('03.03.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pad a 2-digit month', () => {
|
||||||
|
expect(formatGermanDateInput('03.12.')).toBe('03.12.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pads both day and month in a fully typed date', () => {
|
||||||
|
expect(formatGermanDateInput('3.3.2012')).toBe('03.03.2012');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pads only day when month is already 2 digits', () => {
|
||||||
|
expect(formatGermanDateInput('3.12.2024')).toBe('03.12.2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pads only month when day is already 2 digits', () => {
|
||||||
|
expect(formatGermanDateInput('20.3.2024')).toBe('20.03.2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a complete date entered with manual dots and no padding needed', () => {
|
||||||
|
expect(formatGermanDateInput('20.12.2024')).toBe('20.12.2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overflows excess day digits into month when dot follows', () => {
|
||||||
|
expect(formatGermanDateInput('123.')).toBe('12.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps year digits at 4', () => {
|
||||||
|
expect(formatGermanDateInput('03.03.20249')).toBe('03.03.2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overflows excess month digits into year (digit stream then continue typing)', () => {
|
||||||
|
// User typed digits → auto-dot gave "20.12", then types "2" → raw becomes "20.122"
|
||||||
|
expect(formatGermanDateInput('20.122')).toBe('20.12.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues building year after overflow', () => {
|
||||||
|
expect(formatGermanDateInput('20.12.2024')).toBe('20.12.2024');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,19 +23,35 @@ export async function load({ fetch, locals }) {
|
|||||||
if (!hasAnyAdminPerm(user)) throw error(403, getErrorMessage('FORBIDDEN'));
|
if (!hasAnyAdminPerm(user)) throw error(403, getErrorMessage('FORBIDDEN'));
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
|
// TODO: replace with a dedicated /api/admin/stats endpoint that returns counts only,
|
||||||
|
// so the System page does not load full entity lists it does not render.
|
||||||
const [usersResult, groupsResult, tagsResult] = await Promise.all([
|
const [usersResult, groupsResult, tagsResult] = await Promise.all([
|
||||||
api.GET('/api/users'),
|
api.GET('/api/users'),
|
||||||
api.GET('/api/groups'),
|
api.GET('/api/groups'),
|
||||||
api.GET('/api/tags')
|
api.GET('/api/tags')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (!usersResult.response.ok) {
|
||||||
|
const code = (usersResult.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(usersResult.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
if (!groupsResult.response.ok) {
|
||||||
|
const code = (groupsResult.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(groupsResult.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
if (!tagsResult.response.ok) {
|
||||||
|
const code = (tagsResult.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(tagsResult.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userCount: (usersResult.data ?? []).length,
|
userCount: (usersResult.data ?? []).length,
|
||||||
groupCount: (groupsResult.data ?? []).length,
|
groupCount: (groupsResult.data ?? []).length,
|
||||||
tagCount: (tagsResult.data ?? []).length,
|
tagCount: (tagsResult.data ?? []).length,
|
||||||
canManageUsers: hasPerm(user, 'ADMIN_USER'),
|
canManageUsers: hasPerm(user, 'ADMIN_USER'),
|
||||||
canManageTags: hasPerm(user, 'ADMIN_TAG'),
|
canManageTags: hasPerm(user, 'ADMIN_TAG'),
|
||||||
canManageGroups: hasPerm(user, 'ADMIN_PERMISSION'),
|
canManagePermissions: hasPerm(user, 'ADMIN_PERMISSION'),
|
||||||
canRunMaintenance: hasPerm(user, 'ADMIN')
|
canRunMaintenance: hasPerm(user, 'ADMIN')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ let { data, children } = $props();
|
|||||||
-mt-6: cancel the global layout's pt-6 on <main>
|
-mt-6: cancel the global layout's pt-6 on <main>
|
||||||
Height fills from below the global header (64px) to bottom of viewport.
|
Height fills from below the global header (64px) to bottom of viewport.
|
||||||
-->
|
-->
|
||||||
<div class="-mt-6 flex overflow-hidden" style="height: calc(100vh - 65px)">
|
<div class="-mt-6 -mb-6 flex overflow-hidden" style="height: calc(100vh - 65px)">
|
||||||
<!-- Entity Nav: hidden on mobile, icon strip on tablet, full labels on desktop -->
|
<!-- Entity Nav: hidden on mobile, icon strip on tablet, full labels on desktop -->
|
||||||
<div class="hidden md:flex">
|
<div class="hidden md:flex">
|
||||||
<EntityNav
|
<EntityNav
|
||||||
@@ -21,7 +21,7 @@ let { data, children } = $props();
|
|||||||
tagCount={data.tagCount}
|
tagCount={data.tagCount}
|
||||||
canManageUsers={data.canManageUsers}
|
canManageUsers={data.canManageUsers}
|
||||||
canManageTags={data.canManageTags}
|
canManageTags={data.canManageTags}
|
||||||
canManageGroups={data.canManageGroups}
|
canManagePermissions={data.canManagePermissions}
|
||||||
canRunMaintenance={data.canRunMaintenance}
|
canRunMaintenance={data.canRunMaintenance}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ onMount(() => {
|
|||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.canManageGroups}
|
{#if data.canManagePermissions}
|
||||||
<a href="/admin/groups" class="flex items-center justify-between px-4 py-4 hover:bg-muted">
|
<a href="/admin/groups" class="flex items-center justify-between px-4 py-4 hover:bg-muted">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-sans text-sm font-bold text-ink">{m.admin_tab_groups()}</div>
|
<div class="font-sans text-sm font-bold text-ink">{m.admin_tab_groups()}</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ let {
|
|||||||
tagCount,
|
tagCount,
|
||||||
canManageUsers,
|
canManageUsers,
|
||||||
canManageTags,
|
canManageTags,
|
||||||
canManageGroups,
|
canManagePermissions,
|
||||||
canRunMaintenance
|
canRunMaintenance
|
||||||
}: {
|
}: {
|
||||||
userCount: number;
|
userCount: number;
|
||||||
@@ -18,7 +18,7 @@ let {
|
|||||||
tagCount: number;
|
tagCount: number;
|
||||||
canManageUsers: boolean;
|
canManageUsers: boolean;
|
||||||
canManageTags: boolean;
|
canManageTags: boolean;
|
||||||
canManageGroups: boolean;
|
canManagePermissions: boolean;
|
||||||
canRunMaintenance: boolean;
|
canRunMaintenance: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -28,6 +28,9 @@ const isActive = (section: string) => currentPath.startsWith(`/admin/${section}`
|
|||||||
let flyoutOpen = $state(false);
|
let flyoutOpen = $state(false);
|
||||||
let flyoutTriggerElement: HTMLButtonElement | null = null;
|
let flyoutTriggerElement: HTMLButtonElement | null = null;
|
||||||
|
|
||||||
|
// All four section buttons open the same flyout that repeats the full nav.
|
||||||
|
// This is intentional: on tablet the flyout shows all sections as a wider navigation panel,
|
||||||
|
// not a context-specific panel for the clicked section.
|
||||||
async function openFlyout(event: MouseEvent) {
|
async function openFlyout(event: MouseEvent) {
|
||||||
flyoutTriggerElement = event.currentTarget as HTMLButtonElement;
|
flyoutTriggerElement = event.currentTarget as HTMLButtonElement;
|
||||||
flyoutOpen = true;
|
flyoutOpen = true;
|
||||||
@@ -71,6 +74,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
data-flyout-trigger
|
data-flyout-trigger
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={m.admin_tab_users()}
|
aria-label={m.admin_tab_users()}
|
||||||
|
title={m.admin_tab_users()}
|
||||||
onclick={openFlyout}
|
onclick={openFlyout}
|
||||||
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
|
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
|
||||||
{isActive('users')
|
{isActive('users')
|
||||||
@@ -131,12 +135,13 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canManageGroups}
|
{#if canManagePermissions}
|
||||||
<!-- Tablet trigger button (md only, hidden at lg) -->
|
<!-- Tablet trigger button (md only, hidden at lg) -->
|
||||||
<button
|
<button
|
||||||
data-flyout-trigger
|
data-flyout-trigger
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={m.admin_tab_groups()}
|
aria-label={m.admin_tab_groups()}
|
||||||
|
title={m.admin_tab_groups()}
|
||||||
onclick={openFlyout}
|
onclick={openFlyout}
|
||||||
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
|
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
|
||||||
{isActive('groups')
|
{isActive('groups')
|
||||||
@@ -203,6 +208,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
data-flyout-trigger
|
data-flyout-trigger
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={m.admin_tab_tags()}
|
aria-label={m.admin_tab_tags()}
|
||||||
|
title={m.admin_tab_tags()}
|
||||||
onclick={openFlyout}
|
onclick={openFlyout}
|
||||||
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
|
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
|
||||||
{isActive('tags')
|
{isActive('tags')
|
||||||
@@ -273,6 +279,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
data-flyout-trigger
|
data-flyout-trigger
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={m.admin_tab_system()}
|
aria-label={m.admin_tab_system()}
|
||||||
|
title={m.admin_tab_system()}
|
||||||
onclick={openFlyout}
|
onclick={openFlyout}
|
||||||
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-t border-l-[3px] border-white/10 py-3 transition-colors lg:hidden
|
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-t border-l-[3px] border-white/10 py-3 transition-colors lg:hidden
|
||||||
{isActive('system')
|
{isActive('system')
|
||||||
@@ -390,7 +397,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canManageGroups}
|
{#if canManagePermissions}
|
||||||
<a
|
<a
|
||||||
href="/admin/groups"
|
href="/admin/groups"
|
||||||
onclick={closeFlyout}
|
onclick={closeFlyout}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const props = {
|
|||||||
tagCount: 8,
|
tagCount: 8,
|
||||||
canManageUsers: true,
|
canManageUsers: true,
|
||||||
canManageTags: true,
|
canManageTags: true,
|
||||||
canManageGroups: true,
|
canManagePermissions: true,
|
||||||
canRunMaintenance: true
|
canRunMaintenance: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe('admin layout load — permission check', () => {
|
|||||||
expect(result.tagCount).toBe(3);
|
expect(result.tagCount).toBe(3);
|
||||||
expect(result.canManageUsers).toBe(true);
|
expect(result.canManageUsers).toBe(true);
|
||||||
expect(result.canManageTags).toBe(true);
|
expect(result.canManageTags).toBe(true);
|
||||||
expect(result.canManageGroups).toBe(true);
|
expect(result.canManagePermissions).toBe(true);
|
||||||
expect(result.canRunMaintenance).toBe(true);
|
expect(result.canRunMaintenance).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const fullPerms = {
|
|||||||
tagCount: 7,
|
tagCount: 7,
|
||||||
canManageUsers: true,
|
canManageUsers: true,
|
||||||
canManageTags: true,
|
canManageTags: true,
|
||||||
canManageGroups: true,
|
canManagePermissions: true,
|
||||||
canRunMaintenance: true
|
canRunMaintenance: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const fullData = {
|
|||||||
tagCount: 7,
|
tagCount: 7,
|
||||||
canManageUsers: true,
|
canManageUsers: true,
|
||||||
canManageTags: true,
|
canManageTags: true,
|
||||||
canManageGroups: true,
|
canManagePermissions: true,
|
||||||
canRunMaintenance: true
|
canRunMaintenance: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export async function load({ url, fetch }) {
|
export async function load({ url, fetch, locals }) {
|
||||||
const senderId = url.searchParams.get('senderId') || '';
|
const senderId = url.searchParams.get('senderId') || '';
|
||||||
const receiverId = url.searchParams.get('receiverId') || '';
|
const receiverId = url.searchParams.get('receiverId') || '';
|
||||||
const from = url.searchParams.get('from') || '';
|
const from = url.searchParams.get('from') || '';
|
||||||
const to = url.searchParams.get('to') || '';
|
const to = url.searchParams.get('to') || '';
|
||||||
const dir = url.searchParams.get('dir') || 'DESC';
|
const dir = url.searchParams.get('dir') || 'DESC';
|
||||||
|
|
||||||
|
const canWrite =
|
||||||
|
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
|
||||||
|
g.permissions.includes('WRITE_ALL')
|
||||||
|
) ?? false;
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
let documents: components['schemas']['Document'][] = [];
|
let documents: components['schemas']['Document'][] = [];
|
||||||
@@ -30,14 +37,22 @@ export async function load({ url, fetch }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(({ data }) => {
|
.then((result) => {
|
||||||
documents = data ?? [];
|
if (!result.response.ok) {
|
||||||
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
documents = result.data ?? [];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
requests.push(
|
requests.push(
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
|
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then((result) => {
|
||||||
const p = data as { firstName: string; lastName: string } | undefined;
|
if (!result.response.ok) {
|
||||||
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
const p = result.data as { firstName: string; lastName: string } | undefined;
|
||||||
if (p) senderName = `${p.firstName} ${p.lastName}`;
|
if (p) senderName = `${p.firstName} ${p.lastName}`;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -45,8 +60,12 @@ export async function load({ url, fetch }) {
|
|||||||
|
|
||||||
if (receiverId) {
|
if (receiverId) {
|
||||||
requests.push(
|
requests.push(
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
|
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then((result) => {
|
||||||
const p = data as { firstName: string; lastName: string } | undefined;
|
if (!result.response.ok) {
|
||||||
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
const p = result.data as { firstName: string; lastName: string } | undefined;
|
||||||
if (p) receiverName = `${p.firstName} ${p.lastName}`;
|
if (p) receiverName = `${p.firstName} ${p.lastName}`;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -56,6 +75,7 @@ export async function load({ url, fetch }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
documents,
|
documents,
|
||||||
|
canWrite,
|
||||||
initialValues: { senderName, receiverName },
|
initialValues: { senderName, receiverName },
|
||||||
filters: { senderId, receiverId, from, to, dir }
|
filters: { senderId, receiverId, from, to, dir }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,27 +2,53 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import CorrespondenzPersonBar from './CorrespondenzPersonBar.svelte';
|
||||||
import ConversationFilterBar from './ConversationFilterBar.svelte';
|
import CorrespondenzFilterControls from './CorrespondenzFilterControls.svelte';
|
||||||
|
import SinglePersonHintBar from './SinglePersonHintBar.svelte';
|
||||||
import ConversationTimeline from './ConversationTimeline.svelte';
|
import ConversationTimeline from './ConversationTimeline.svelte';
|
||||||
|
import CorrespondenzEmptyState from './CorrespondenzEmptyState.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
|
// Filter values are local $state so swapPersons/toggleSort can mutate them before goto.
|
||||||
|
// They are initialised once from server data and never re-synced — navigation replaces
|
||||||
|
// the page component, so each load gets a fresh init.
|
||||||
let senderId = $state(untrack(() => data.filters.senderId));
|
let senderId = $state(untrack(() => data.filters.senderId));
|
||||||
let receiverId = $state(untrack(() => data.filters.receiverId));
|
let receiverId = $state(untrack(() => data.filters.receiverId));
|
||||||
let fromDate = $state(untrack(() => data.filters.from));
|
let fromDate = $state(untrack(() => data.filters.from));
|
||||||
let toDate = $state(untrack(() => data.filters.to));
|
let toDate = $state(untrack(() => data.filters.to));
|
||||||
let sortDir = $state(untrack(() => data.filters.dir));
|
let sortDir = $state(untrack(() => data.filters.dir));
|
||||||
|
|
||||||
// Sync with server data after navigation
|
// Names are pure reads of server data — no local mutation needed.
|
||||||
|
const senderName = $derived(data.initialValues.senderName);
|
||||||
|
const receiverName = $derived(data.initialValues.receiverName);
|
||||||
|
|
||||||
|
// Side-effect only: persist the resolved sender to localStorage once the name is available.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
senderId = data.filters.senderId;
|
if (data.filters.senderId && data.initialValues.senderName) {
|
||||||
receiverId = data.filters.receiverId;
|
persistRecentPerson(data.filters.senderId, data.initialValues.senderName);
|
||||||
fromDate = data.filters.from;
|
}
|
||||||
toDate = data.filters.to;
|
|
||||||
sortDir = data.filters.dir;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isSinglePerson = $derived(!!senderId && !receiverId);
|
||||||
|
|
||||||
|
const RECENT_STORAGE_KEY = 'korrespondenz_recent_persons';
|
||||||
|
const MAX_RECENT = 5;
|
||||||
|
|
||||||
|
function persistRecentPerson(id: string, name: string) {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
|
||||||
|
const existing: { id: string; name: string }[] = raw ? JSON.parse(raw) : [];
|
||||||
|
const filtered = existing.filter((p) => p.id !== id);
|
||||||
|
const updated = [{ id, name }, ...filtered].slice(0, MAX_RECENT);
|
||||||
|
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(updated));
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable — silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
const params = new SvelteURLSearchParams();
|
const params = new SvelteURLSearchParams();
|
||||||
if (senderId) params.set('senderId', senderId);
|
if (senderId) params.set('senderId', senderId);
|
||||||
@@ -44,51 +70,59 @@ function swapPersons() {
|
|||||||
receiverId = tmp;
|
receiverId = tmp;
|
||||||
applyFilters();
|
applyFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectPerson(id: string) {
|
||||||
|
if (!id) {
|
||||||
|
document.querySelector<HTMLInputElement>('#senderId-search')?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
senderId = id;
|
||||||
|
receiverId = '';
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-5xl px-4 py-10">
|
<!-- Strips — pulled up to negate main's py-6 top padding so they sit flush -->
|
||||||
<!-- Page Header -->
|
<div class="-mt-6">
|
||||||
<div class="mb-8 border-b border-ink/10 pb-4">
|
<!-- Strip: Row 1 — full width, no container -->
|
||||||
<h1 class="font-serif text-3xl font-medium text-ink">{m.conv_heading()}</h1>
|
<CorrespondenzPersonBar
|
||||||
<p class="mt-2 font-sans text-sm text-ink-2">
|
|
||||||
{m.conv_subtitle()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConversationFilterBar
|
|
||||||
bind:senderId={senderId}
|
bind:senderId={senderId}
|
||||||
bind:receiverId={receiverId}
|
bind:receiverId={receiverId}
|
||||||
bind:fromDate={fromDate}
|
|
||||||
bind:toDate={toDate}
|
|
||||||
bind:sortDir={sortDir}
|
|
||||||
initialSenderName={data.initialValues.senderName}
|
initialSenderName={data.initialValues.senderName}
|
||||||
initialReceiverName={data.initialValues.receiverName}
|
initialReceiverName={data.initialValues.receiverName}
|
||||||
onapplyFilters={applyFilters}
|
onapplyFilters={applyFilters}
|
||||||
ontoggleSort={toggleSort}
|
|
||||||
onswapPersons={swapPersons}
|
onswapPersons={swapPersons}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- RESULTS LIST SECTION -->
|
<!-- Strip: Row 2 — full width -->
|
||||||
{#if !senderId || !receiverId}
|
<CorrespondenzFilterControls
|
||||||
<div
|
senderId={senderId}
|
||||||
class="flex flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface py-24 text-center"
|
bind:fromDate={fromDate}
|
||||||
>
|
bind:toDate={toDate}
|
||||||
<div class="mb-4 rounded-full bg-muted p-4 text-ink">
|
bind:sortDir={sortDir}
|
||||||
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
documentCount={data.documents.length}
|
||||||
><path
|
onapplyFilters={applyFilters}
|
||||||
stroke-linecap="round"
|
ontoggleSort={toggleSort}
|
||||||
stroke-linejoin="round"
|
/>
|
||||||
stroke-width="2"
|
|
||||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
<!-- Single-person hint bar -->
|
||||||
/></svg
|
{#if isSinglePerson}
|
||||||
>
|
<SinglePersonHintBar
|
||||||
</div>
|
senderName={senderName}
|
||||||
<p class="font-serif text-lg text-ink">{m.conv_empty_heading()}</p>
|
fromDate={fromDate || undefined}
|
||||||
<p class="mt-1 font-sans text-sm text-ink-2">{m.conv_empty_text()}</p>
|
toDate={toDate || undefined}
|
||||||
</div>
|
sortDir={sortDir}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content area with padding -->
|
||||||
|
<div class="px-[18px] py-[14px]">
|
||||||
|
{#if !senderId}
|
||||||
|
<CorrespondenzEmptyState onSelectPerson={selectPerson} />
|
||||||
{: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-line bg-surface py-24 text-center shadow-sm"
|
class="flex flex-col items-center justify-center rounded-sm border border-line bg-muted py-24 text-center shadow-sm"
|
||||||
>
|
>
|
||||||
<p class="font-serif text-ink">{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-ink-3">{m.conv_no_results_text()}</p>
|
<p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p>
|
||||||
@@ -99,6 +133,8 @@ function swapPersons() {
|
|||||||
senderId={senderId}
|
senderId={senderId}
|
||||||
receiverId={receiverId}
|
receiverId={receiverId}
|
||||||
canWrite={data.canWrite}
|
canWrite={data.canWrite}
|
||||||
|
senderName={senderName}
|
||||||
|
receiverName={receiverName}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { conv_no_party } from '$lib/messages-extra';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
documents: {
|
documents: {
|
||||||
@@ -51,23 +50,26 @@ const outPct = $derived(documents.length > 0 ? (outCount / documents.length) * 1
|
|||||||
|
|
||||||
const isBilateral = $derived(!!senderId && !!receiverId);
|
const isBilateral = $derived(!!senderId && !!receiverId);
|
||||||
|
|
||||||
|
const shortSenderName = $derived(senderName?.split(' ')[0] ?? senderName ?? '');
|
||||||
|
const shortReceiverName = $derived(receiverName?.split(' ')[0] ?? receiverName ?? '');
|
||||||
|
|
||||||
function statusDotClass(status: string): string {
|
function statusDotClass(status: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
PLACEHOLDER: 'bg-yellow-400',
|
PLACEHOLDER: 'bg-brand-sand',
|
||||||
UPLOADED: 'bg-green-500',
|
UPLOADED: 'bg-brand-mint',
|
||||||
TRANSCRIBED: 'bg-blue-500',
|
TRANSCRIBED: 'bg-brand-mint',
|
||||||
REVIEWED: 'bg-purple-500',
|
REVIEWED: 'bg-brand-navy/70',
|
||||||
ARCHIVED: 'bg-gray-500'
|
ARCHIVED: 'bg-brand-navy'
|
||||||
};
|
};
|
||||||
return map[status] ?? 'bg-gray-300';
|
return map[status] ?? 'bg-brand-sand';
|
||||||
}
|
}
|
||||||
|
|
||||||
function otherPartyName(doc: (typeof documents)[number]): string {
|
function otherPartyName(doc: (typeof documents)[number]): string {
|
||||||
if (doc.sender?.id === senderId) {
|
if (doc.sender?.id === senderId) {
|
||||||
const r = doc.receivers?.[0];
|
const r = doc.receivers?.[0];
|
||||||
return r ? `${r.firstName} ${r.lastName}` : conv_no_party();
|
return r ? `${r.firstName} ${r.lastName}` : m.conv_no_party();
|
||||||
}
|
}
|
||||||
return doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : conv_no_party();
|
return doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : m.conv_no_party();
|
||||||
}
|
}
|
||||||
|
|
||||||
const newDocUrl = $derived(
|
const newDocUrl = $derived(
|
||||||
@@ -77,30 +79,30 @@ const newDocUrl = $derived(
|
|||||||
|
|
||||||
{#if isBilateral && documents.length > 0}
|
{#if isBilateral && documents.length > 0}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-1 border-b border-[#E8E4DF] bg-[#F7F5F2] px-[18px] py-2"
|
class="flex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Briefverteilung in diesem Zeitraum: {outCount} von {senderName ?? ''}, {inCount} von {receiverName ?? ''}"
|
aria-label="Briefverteilung in diesem Zeitraum: {outCount} von {senderName ?? ''}, {inCount} von {receiverName ?? ''}"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between text-[10px] font-bold">
|
<div class="flex justify-between text-sm font-bold">
|
||||||
<span class="text-[#002850]">{outCount} von {senderName} →</span>
|
<span class="text-primary">{outCount} von {shortSenderName} →</span>
|
||||||
<span class="text-[#0F5755]">{inCount} von {receiverName} ←</span>
|
<span class="text-accent">{inCount} von {shortReceiverName} ←</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex h-[5px] overflow-hidden rounded-full bg-[#E0DDD6]">
|
<div class="flex h-[5px] overflow-hidden rounded-full bg-line">
|
||||||
<div class="h-full bg-[#002850] transition-all" style="width: {outPct}%"></div>
|
<div class="h-full bg-primary transition-all" style="width: {outPct}%"></div>
|
||||||
<div class="h-full bg-[#A6DAD8] transition-all" style="width: {100 - outPct}%"></div>
|
<div class="h-full bg-accent transition-all" style="width: {100 - outPct}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-sm border border-[#E0DDD6] bg-white">
|
<div class="overflow-hidden rounded-sm border border-line bg-surface">
|
||||||
{#each enrichedDocuments as { doc, year, showYearDivider, isOut } (doc.id)}
|
{#each enrichedDocuments as { doc, year, showYearDivider, isOut } (doc.id)}
|
||||||
{#if showYearDivider && year !== null}
|
{#if showYearDivider && year !== null}
|
||||||
<div
|
<div
|
||||||
data-testid="year-divider"
|
data-testid="year-divider"
|
||||||
class="flex items-baseline gap-2 border-t-2 border-b border-[#C8C4BE] border-[#D8D4CE] bg-[#F0EDE6] px-[14px] py-[6px]"
|
class="flex items-baseline gap-3 border-t-2 border-b border-line bg-muted px-[14px] py-[8px]"
|
||||||
>
|
>
|
||||||
<span class="text-base font-black tracking-tight text-[#002850]">{year}</span>
|
<span class="text-2xl font-black tracking-tight text-primary">{year}</span>
|
||||||
<span class="text-xs font-bold text-[#AAA]">{countsByYear.get(year) ?? 0} Briefe</span>
|
<span class="text-sm font-bold text-ink-3">{countsByYear.get(year) ?? 0} Briefe</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -109,31 +111,31 @@ const newDocUrl = $derived(
|
|||||||
aria-label="{doc.title || doc.originalFilename}, {doc.documentDate
|
aria-label="{doc.title || doc.originalFilename}, {doc.documentDate
|
||||||
? formatDate(doc.documentDate)
|
? formatDate(doc.documentDate)
|
||||||
: ''}"
|
: ''}"
|
||||||
class="group flex min-h-[44px] cursor-pointer items-start gap-[9px] border-b border-l-[3px] border-[#EDEBE4] px-[14px] py-[10px] transition-colors last:border-b-0 hover:bg-[#F7F5F2]"
|
class="group flex min-h-[44px] cursor-pointer items-center gap-[9px] border-b border-l-[3px] border-line-2 px-[14px] py-[10px] transition-colors last:border-b-0 hover:bg-muted"
|
||||||
class:border-l-[#002850]={isOut}
|
class:border-l-primary={isOut}
|
||||||
class:border-l-[#A6DAD8]={!isOut}
|
class:border-l-accent={!isOut}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="w-[14px] shrink-0 pt-[1px] text-xs font-black"
|
class="w-[16px] shrink-0 text-sm font-black"
|
||||||
class:text-[#002850]={isOut}
|
class:text-primary={isOut}
|
||||||
class:text-[#0F5755]={!isOut}
|
class:text-accent={!isOut}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{isOut ? '→' : '←'}
|
{isOut ? '→' : '←'}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="mb-[2px] truncate text-sm font-bold text-[#0D2240]">
|
<div class="mb-[2px] truncate text-sm font-bold text-ink">
|
||||||
{doc.title || doc.originalFilename}
|
{doc.title || doc.originalFilename}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-[5px] text-xs text-[#888]">
|
<div class="flex items-center gap-[5px] text-sm text-ink-3">
|
||||||
<span>{doc.documentDate ? formatDate(doc.documentDate) : '—'}</span>
|
<span>{doc.documentDate ? formatDate(doc.documentDate) : '—'}</span>
|
||||||
{#if doc.location}
|
{#if doc.location}
|
||||||
<span class="text-[#D1CCC8]">·</span>
|
<span class="text-line">·</span>
|
||||||
<span>{doc.location}</span>
|
<span>{doc.location}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !receiverId}
|
{#if !receiverId}
|
||||||
<span class="text-[#D1CCC8]">·</span>
|
<span class="text-line">·</span>
|
||||||
<span>{otherPartyName(doc)}</span>
|
<span>{otherPartyName(doc)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<span
|
||||||
@@ -144,18 +146,18 @@ const newDocUrl = $derived(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="shrink-0 text-[#888] opacity-0 transition-opacity group-hover:opacity-100"
|
class="shrink-0 text-sm text-ink-3 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
aria-hidden="true">›</span
|
aria-hidden="true">›</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if canWrite}
|
{#if canWrite}
|
||||||
<div class="flex justify-end border-t border-[#E8E4DF] px-[14px] py-[6px]">
|
<div class="flex justify-end border-t border-line px-[14px] py-[6px]">
|
||||||
<a
|
<a
|
||||||
href={newDocUrl}
|
href={newDocUrl}
|
||||||
data-testid="conv-new-doc-link"
|
data-testid="conv-new-doc-link"
|
||||||
class="inline-flex items-center gap-1 text-xs font-bold text-[#002850]/50 transition-colors hover:text-[#002850]"
|
class="inline-flex items-center gap-1 text-xs font-bold text-primary/50 transition-colors hover:text-primary"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-3 w-3"
|
class="h-3 w-3"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { conv_suggestions_heading, conv_suggestions_all_label } from '$lib/messages-extra';
|
|
||||||
|
|
||||||
interface Correspondent {
|
interface Correspondent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -9,29 +8,14 @@ interface Correspondent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
senderId: string;
|
correspondents: Correspondent[];
|
||||||
|
loading: boolean;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
onselect: (id: string) => void;
|
onselect: (id: string) => void;
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { senderId, senderName, onselect, onclose }: Props = $props();
|
let { correspondents, loading, senderName, onselect, onclose }: Props = $props();
|
||||||
|
|
||||||
let results = $state<Correspondent[]>([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/persons/${senderId}/correspondents`);
|
|
||||||
results = res.ok ? await res.json() : [];
|
|
||||||
} catch {
|
|
||||||
results = [];
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
|
|
||||||
function clickOutside(node: HTMLElement) {
|
function clickOutside(node: HTMLElement) {
|
||||||
const handleClick = (event: MouseEvent) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
@@ -78,52 +62,51 @@ function getInitials(person: Correspondent): string {
|
|||||||
use:clickOutside
|
use:clickOutside
|
||||||
role="listbox"
|
role="listbox"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-label={conv_suggestions_heading()}
|
aria-label={m.conv_suggestions_heading()}
|
||||||
class="absolute top-full right-0 left-0 z-30 mt-1 rounded-sm border border-[#E0DDD6] bg-white shadow-lg"
|
class="absolute top-full right-0 left-0 z-30 mt-1 rounded-sm border border-line bg-surface shadow-lg"
|
||||||
onkeydown={(e) => handleKeydown(e, e.currentTarget as HTMLElement)}
|
onkeydown={(e) => handleKeydown(e, e.currentTarget as HTMLElement)}
|
||||||
>
|
>
|
||||||
<!-- Heading -->
|
<!-- Heading -->
|
||||||
<div class="px-3 pt-2 pb-1 text-[10px] font-bold tracking-widest text-[#888] uppercase">
|
<div class="px-3 pt-2 pb-1 text-[10px] font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{conv_suggestions_heading()}
|
{m.conv_suggestions_heading()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Correspondent rows -->
|
<!-- Correspondent rows -->
|
||||||
{#if !loading}
|
{#if !loading}
|
||||||
{#each results as person (person.id)}
|
{#each correspondents as person (person.id)}
|
||||||
<div
|
<div
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected="false"
|
aria-selected="false"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-[#333] hover:bg-[#F7F5F2] focus:bg-[#F7F5F2] focus:outline-none"
|
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none"
|
||||||
onclick={() => onselect(person.id)}
|
onclick={() => onselect(person.id)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && onselect(person.id)}
|
onkeydown={(e) => e.key === 'Enter' && onselect(person.id)}
|
||||||
>
|
>
|
||||||
<!-- Avatar with initials -->
|
<!-- Avatar with initials -->
|
||||||
<span
|
<span
|
||||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#002850] text-[10px] font-bold text-white"
|
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{getInitials(person)}
|
{getInitials(person)}
|
||||||
</span>
|
</span>
|
||||||
<!-- Svelte auto-escapes — do not use {@html} here. -->
|
<!-- Svelte auto-escapes — do not use {@html} here. -->
|
||||||
{person.lastName}, {person.firstName}
|
{person.lastName}, {person.firstName}
|
||||||
<!-- TODO: show proportional letter-count bar when counts are available from the API -->
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Separator -->
|
<!-- Separator -->
|
||||||
<div class="mt-1 border-t border-[#E0DDD6]"></div>
|
<div class="mt-1 border-t border-line"></div>
|
||||||
|
|
||||||
<!-- "Alle Korrespondenten" row -->
|
<!-- "Alle Korrespondenten" row -->
|
||||||
<div
|
<div
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected="false"
|
aria-selected="false"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-[#333] hover:bg-[#F7F5F2] focus:bg-[#F7F5F2] focus:outline-none"
|
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none"
|
||||||
onclick={() => onselect('')}
|
onclick={() => onselect('')}
|
||||||
onkeydown={(e) => e.key === 'Enter' && onselect('')}
|
onkeydown={(e) => e.key === 'Enter' && onselect('')}
|
||||||
>
|
>
|
||||||
{conv_suggestions_all_label({ name: senderName })}
|
{m.conv_suggestions_all_label({ name: senderName })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { conv_empty_search_placeholder, conv_empty_recent_label } from '$lib/messages-extra';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
interface RecentPerson {
|
interface RecentPerson {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
name: string;
|
||||||
lastName: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -29,20 +28,20 @@ onMount(() => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto flex max-w-sm flex-col items-center gap-4 py-16 text-center">
|
<div class="mx-auto flex max-w-lg flex-col items-center gap-5 py-12 text-center">
|
||||||
<!-- Icon circle -->
|
<!-- Icon circle -->
|
||||||
<div class="rounded-full bg-[#F0EDE6] p-4">
|
<div class="rounded-full bg-muted p-5">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="24"
|
width="36"
|
||||||
height="24"
|
height="36"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
class="text-[#002850]"
|
class="text-primary"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
@@ -51,52 +50,52 @@ onMount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Heading -->
|
<!-- Heading -->
|
||||||
<h2 class="font-serif text-sm font-black text-[#0D2240]">Korrespondenz durchsuchen</h2>
|
<h2 class="font-serif text-xl font-black text-ink">{m.conv_empty_heading()}</h2>
|
||||||
|
|
||||||
<!-- Subtext -->
|
<!-- Subtext -->
|
||||||
<p class="max-w-[280px] text-xs text-[#888]">
|
<p class="max-w-sm text-base text-ink-3">
|
||||||
Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.
|
{m.conv_empty_text()}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Search input placeholder (visual only — clicking focuses Person A typeahead above) -->
|
<!-- Search input placeholder (visual only — clicking focuses Person A typeahead above) -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="conv-empty-search"
|
data-testid="conv-empty-search"
|
||||||
aria-label={conv_empty_search_placeholder()}
|
aria-label={m.conv_empty_search_placeholder()}
|
||||||
onclick={() => onSelectPerson('')}
|
onclick={() => onSelectPerson('')}
|
||||||
class="flex h-[28px] w-[260px] items-center rounded-sm border border-[#D1D5DB] bg-[#F9F8F6] px-3 text-xs text-[#AAA] italic transition-colors hover:border-[#002850]"
|
class="flex h-10 w-full max-w-sm items-center rounded border border-line bg-muted px-4 text-sm text-ink-3 italic transition-colors hover:border-primary"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="12"
|
width="14"
|
||||||
height="12"
|
height="14"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
class="mr-1.5 shrink-0"
|
class="mr-2 shrink-0"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
<path d="m21 21-4.35-4.35" />
|
<path d="m21 21-4.35-4.35" />
|
||||||
</svg>
|
</svg>
|
||||||
Person suchen…
|
{m.conv_empty_search_placeholder()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- Recent persons — only shown when localStorage has entries -->
|
||||||
<div class="flex w-full items-center gap-2">
|
|
||||||
<div class="flex-1 border-t border-[#E0DDD6]"></div>
|
|
||||||
<span class="text-[10px] font-bold tracking-wider text-[#AAA] uppercase">oder</span>
|
|
||||||
<div class="flex-1 border-t border-[#E0DDD6]"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recent persons -->
|
|
||||||
{#if recentPersons.length > 0}
|
{#if recentPersons.length > 0}
|
||||||
<div class="flex w-full flex-col items-center gap-2">
|
<!-- Divider -->
|
||||||
<span class="text-[10px] font-bold tracking-widest text-[#888] uppercase">
|
<div class="flex w-full max-w-sm items-center gap-2">
|
||||||
{conv_empty_recent_label()}
|
<div class="flex-1 border-t border-line"></div>
|
||||||
|
<span class="text-xs font-bold tracking-wider text-ink-3 uppercase">oder</span>
|
||||||
|
<div class="flex-1 border-t border-line"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full max-w-sm flex-col items-center gap-3">
|
||||||
|
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.conv_empty_recent_label()}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex flex-wrap justify-center gap-2">
|
<div class="flex flex-wrap justify-center gap-2">
|
||||||
{#each recentPersons as person (person.id)}
|
{#each recentPersons as person (person.id)}
|
||||||
@@ -104,15 +103,15 @@ onMount(() => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => onSelectPerson(person.id)}
|
onclick={() => onSelectPerson(person.id)}
|
||||||
class="flex items-center gap-1.5 rounded-full border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-bold text-[#333] transition-colors hover:border-[#002850] hover:text-[#002850]"
|
class="flex items-center gap-2 rounded-full border border-line bg-surface px-4 py-2 text-sm font-bold text-ink transition-colors hover:border-primary hover:text-primary"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#002850] text-[10px] text-white"
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-xs text-primary-fg"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{person.firstName[0]}{person.lastName[0]}
|
{person.name.charAt(0).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span class="hidden sm:inline">{person.lastName}, </span>{person.firstName}
|
{person.name}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
conv_strip_period,
|
import DateInput from '$lib/components/DateInput.svelte';
|
||||||
conv_strip_from_placeholder,
|
|
||||||
conv_strip_to_placeholder,
|
|
||||||
conv_strip_sort_newest,
|
|
||||||
conv_strip_sort_oldest,
|
|
||||||
conv_letters_count
|
|
||||||
} from '$lib/messages-extra';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
senderId: string;
|
senderId: string;
|
||||||
@@ -33,56 +27,42 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-[10px] border-b border-[#E0DDD6] bg-[#F7F5F2] px-4 py-[5px] transition-opacity sm:px-[18px]"
|
class="flex items-center gap-[10px] border-b border-line bg-muted px-4 py-[5px] transition-opacity sm:px-[18px]"
|
||||||
class:opacity-40={!senderId}
|
class:opacity-40={!senderId}
|
||||||
class:pointer-events-none={!senderId}
|
class:pointer-events-none={!senderId}
|
||||||
aria-disabled={!senderId}
|
aria-disabled={!senderId}
|
||||||
>
|
>
|
||||||
<!-- Period label -->
|
<!-- Period label -->
|
||||||
<span class="hidden text-xs font-bold text-[#888] sm:block">
|
<span class="hidden text-xs font-bold tracking-wide text-ink-3 uppercase sm:block">
|
||||||
{conv_strip_period()}
|
{m.conv_strip_period()}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- From date -->
|
<!-- From date -->
|
||||||
<input
|
<DateInput
|
||||||
type="date"
|
|
||||||
bind:value={fromDate}
|
bind:value={fromDate}
|
||||||
onchange={() => onapplyFilters()}
|
onchange={() => onapplyFilters()}
|
||||||
placeholder={conv_strip_from_placeholder()}
|
placeholder={m.conv_strip_from_placeholder()}
|
||||||
aria-label="Von"
|
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none {fromDate ? 'border-primary' : 'border-line'}"
|
||||||
class="h-[22px] min-h-[44px] w-[80px] rounded-[3px] border px-1 text-xs focus:outline-none sm:min-h-0"
|
|
||||||
class:border-[#002850]={!!fromDate}
|
|
||||||
class:text-[#333]={!!fromDate}
|
|
||||||
class:border-[#D1D5DB]={!fromDate}
|
|
||||||
class:text-[#AAA]={!fromDate}
|
|
||||||
class:italic={!fromDate}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span class="text-xs text-[#AAA]">–</span>
|
<span class="text-xs text-ink-3">–</span>
|
||||||
|
|
||||||
<!-- To date -->
|
<!-- To date -->
|
||||||
<input
|
<DateInput
|
||||||
type="date"
|
|
||||||
bind:value={toDate}
|
bind:value={toDate}
|
||||||
onchange={() => onapplyFilters()}
|
onchange={() => onapplyFilters()}
|
||||||
placeholder={conv_strip_to_placeholder()}
|
placeholder={m.conv_strip_to_placeholder()}
|
||||||
aria-label="Bis"
|
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none {toDate ? 'border-primary' : 'border-line'}"
|
||||||
class="h-[22px] min-h-[44px] w-[80px] rounded-[3px] border px-1 text-xs focus:outline-none sm:min-h-0"
|
|
||||||
class:border-[#002850]={!!toDate}
|
|
||||||
class:text-[#333]={!!toDate}
|
|
||||||
class:border-[#D1D5DB]={!toDate}
|
|
||||||
class:text-[#AAA]={!toDate}
|
|
||||||
class:italic={!toDate}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Document count -->
|
<!-- Document count -->
|
||||||
<span
|
<span
|
||||||
data-testid="conv-strip-count"
|
data-testid="conv-strip-count"
|
||||||
class="ml-auto text-xs font-bold"
|
class="ml-auto text-xs font-bold"
|
||||||
class:text-[#002850]={hasDateFilter}
|
class:text-primary={hasDateFilter}
|
||||||
class:text-[#888]={!hasDateFilter}
|
class:text-ink-3={!hasDateFilter}
|
||||||
>
|
>
|
||||||
{conv_letters_count({ count: documentCount ?? 0 })}
|
{m.conv_letters_count({ count: documentCount ?? 0 })}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Sort button -->
|
<!-- Sort button -->
|
||||||
@@ -92,14 +72,14 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
|
|||||||
aria-label="Sortierung umkehren"
|
aria-label="Sortierung umkehren"
|
||||||
aria-pressed={sortDir === 'ASC'}
|
aria-pressed={sortDir === 'ASC'}
|
||||||
onclick={ontoggleSort}
|
onclick={ontoggleSort}
|
||||||
class="flex h-[22px] min-h-[44px] items-center gap-1 rounded-[3px] border px-2 text-xs font-bold sm:min-h-0"
|
class="flex h-8 min-h-[44px] items-center gap-1 rounded border px-3 text-xs font-bold"
|
||||||
class:border-[#002850]={isActive}
|
class:border-primary={isActive}
|
||||||
class:text-[#002850]={isActive}
|
class:text-primary={isActive}
|
||||||
class:border-[#D1D5DB]={!isActive}
|
class:border-line={!isActive}
|
||||||
class:text-[#888]={!isActive}
|
class:text-ink-3={!isActive}
|
||||||
>
|
>
|
||||||
{#if sortDir === 'ASC'}
|
{#if sortDir === 'ASC'}
|
||||||
{conv_strip_sort_oldest()}
|
{m.conv_strip_sort_oldest()}
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="10"
|
width="10"
|
||||||
@@ -115,7 +95,7 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
|
|||||||
<polyline points="18 15 12 9 6 15" />
|
<polyline points="18 15 12 9 6 15" />
|
||||||
</svg>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
{conv_strip_sort_newest()}
|
{m.conv_strip_sort_newest()}
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="10"
|
width="10"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
|
import CorrespondentSuggestionsDropdown from './CorrespondentSuggestionsDropdown.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
@@ -19,10 +20,40 @@ let {
|
|||||||
onswapPersons
|
onswapPersons
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
interface Correspondent {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
let swapVisible = $derived(!!(senderId && receiverId));
|
let swapVisible = $derived(!!(senderId && receiverId));
|
||||||
|
|
||||||
|
let showSuggestions = $state(false);
|
||||||
|
let correspondents = $state<Correspondent[]>([]);
|
||||||
|
let loadingCorrespondents = $state(false);
|
||||||
|
|
||||||
|
async function handleCorrespondentFocused() {
|
||||||
|
if (!senderId) return;
|
||||||
|
showSuggestions = true;
|
||||||
|
loadingCorrespondents = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/persons/${senderId}/correspondents`);
|
||||||
|
correspondents = res.ok ? await res.json() : [];
|
||||||
|
} catch {
|
||||||
|
correspondents = [];
|
||||||
|
} finally {
|
||||||
|
loadingCorrespondents = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSuggestionSelect(id: string) {
|
||||||
|
receiverId = id;
|
||||||
|
showSuggestions = false;
|
||||||
|
onapplyFilters();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-end gap-[9px] border-b border-[#EAE7E0] bg-white px-4 py-[9px] sm:px-[18px]">
|
<div class="flex items-end gap-[9px] border-b border-line bg-surface px-4 py-[9px] sm:px-[18px]">
|
||||||
<!-- Person A -->
|
<!-- Person A -->
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
@@ -30,6 +61,7 @@ let swapVisible = $derived(!!(senderId && receiverId));
|
|||||||
label="Person"
|
label="Person"
|
||||||
bind:value={senderId}
|
bind:value={senderId}
|
||||||
initialName={initialSenderName}
|
initialName={initialSenderName}
|
||||||
|
compact={true}
|
||||||
restrictToCorrespondentsOf={receiverId || undefined}
|
restrictToCorrespondentsOf={receiverId || undefined}
|
||||||
onchange={() => onapplyFilters()}
|
onchange={() => onapplyFilters()}
|
||||||
/>
|
/>
|
||||||
@@ -41,7 +73,7 @@ let swapVisible = $derived(!!(senderId && receiverId));
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="Personen tauschen"
|
aria-label="Personen tauschen"
|
||||||
onclick={onswapPersons}
|
onclick={onswapPersons}
|
||||||
class="mb-1 flex h-7 w-7 shrink-0 items-center justify-center rounded border border-[#D1D5DB] bg-white text-[#888] transition-colors hover:border-[#002850] hover:text-[#002850]"
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded border border-line bg-surface text-ink-3 transition-colors hover:border-primary hover:text-primary"
|
||||||
class:opacity-0={!swapVisible}
|
class:opacity-0={!swapVisible}
|
||||||
class:pointer-events-none={!swapVisible}
|
class:pointer-events-none={!swapVisible}
|
||||||
tabindex={swapVisible ? 0 : -1}
|
tabindex={swapVisible ? 0 : -1}
|
||||||
@@ -65,23 +97,33 @@ let swapVisible = $derived(!!(senderId && receiverId));
|
|||||||
|
|
||||||
<!-- Korrespondent field -->
|
<!-- Korrespondent field -->
|
||||||
<div
|
<div
|
||||||
class="min-w-0 flex-1"
|
class="relative min-w-0 flex-1"
|
||||||
class:[&_input]:border-dashed={!receiverId}
|
class:[&_input]:border-dashed={!receiverId}
|
||||||
class:[&_input]:border-solid={!!receiverId}
|
class:[&_input]:border-solid={!!receiverId}
|
||||||
class:[&_input]:bg-[#F9F8F6]={!receiverId}
|
class:[&_input]:bg-canvas={!receiverId}
|
||||||
>
|
>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="receiverId"
|
name="receiverId"
|
||||||
label={receiverId ? 'Korrespondent' : 'Korrespondent'}
|
label={receiverId ? 'Korrespondent' : 'Korrespondent — optional'}
|
||||||
bind:value={receiverId}
|
bind:value={receiverId}
|
||||||
initialName={initialReceiverName}
|
initialName={initialReceiverName}
|
||||||
|
compact={true}
|
||||||
|
placeholder="Alle Korrespondenten"
|
||||||
restrictToCorrespondentsOf={senderId || undefined}
|
restrictToCorrespondentsOf={senderId || undefined}
|
||||||
onchange={() => onapplyFilters()}
|
onchange={() => {
|
||||||
|
showSuggestions = false;
|
||||||
|
onapplyFilters();
|
||||||
|
}}
|
||||||
|
onfocused={handleCorrespondentFocused}
|
||||||
/>
|
/>
|
||||||
{#if !receiverId}
|
{#if showSuggestions && senderId && !receiverId}
|
||||||
<span class="pointer-events-none absolute -mt-[1px] text-[11px] text-[#AAA] italic">
|
<CorrespondentSuggestionsDropdown
|
||||||
— optional
|
correspondents={correspondents}
|
||||||
</span>
|
loading={loadingCorrespondents}
|
||||||
|
senderName=""
|
||||||
|
onselect={handleSuggestionSelect}
|
||||||
|
onclose={() => (showSuggestions = false)}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
conv_hint_single_person,
|
|
||||||
conv_hint_single_person_filtered,
|
|
||||||
conv_strip_sort_newest,
|
|
||||||
conv_strip_sort_oldest
|
|
||||||
} from '$lib/messages-extra';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
senderName: string;
|
senderName: string;
|
||||||
@@ -16,23 +11,40 @@ interface Props {
|
|||||||
let { senderName, fromDate = '', toDate = '', sortDir = 'DESC' }: Props = $props();
|
let { senderName, fromDate = '', toDate = '', sortDir = 'DESC' }: Props = $props();
|
||||||
|
|
||||||
let hasDateFilter = $derived(!!(fromDate || toDate));
|
let hasDateFilter = $derived(!!(fromDate || toDate));
|
||||||
|
let sortLabel = $derived(
|
||||||
let sortLabel = $derived(sortDir === 'ASC' ? conv_strip_sort_oldest() : conv_strip_sort_newest());
|
sortDir === 'ASC' ? m.conv_strip_sort_oldest() : m.conv_strip_sort_newest()
|
||||||
|
);
|
||||||
|
let fromYear = $derived(fromDate ? fromDate.substring(0, 4) : '');
|
||||||
|
let toYear = $derived(toDate ? toDate.substring(0, 4) : '');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-[5px] border-b border-[#FDBA74] bg-[#FFF7ED] px-[18px] py-[6px] text-xs text-[#92400E]"
|
class="flex items-center gap-[5px] border-b border-accent bg-accent-bg px-[18px] py-[6px] text-xs text-ink"
|
||||||
>
|
>
|
||||||
<span class="text-sm" aria-hidden="true">📋</span>
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="shrink-0"
|
||||||
|
>
|
||||||
|
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" />
|
||||||
|
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
{#if hasDateFilter}
|
{#if hasDateFilter}
|
||||||
{conv_hint_single_person_filtered({
|
<strong>{senderName}</strong>
|
||||||
name: senderName,
|
<span>·</span>
|
||||||
from: fromDate ?? '',
|
<span>{fromYear}–{toYear}</span>
|
||||||
to: toDate ?? '',
|
<span>·</span>
|
||||||
sortLabel
|
<span>{sortLabel}</span>
|
||||||
})}
|
|
||||||
{:else}
|
{:else}
|
||||||
{conv_hint_single_person({ name: senderName })}
|
Alle Briefe von <strong>{senderName}</strong> — wähle einen Korrespondenten oben um einzugrenzen
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
146
frontend/src/routes/korrespondenz/page.server.spec.ts
Normal file
146
frontend/src/routes/korrespondenz/page.server.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { load } from './+page.server';
|
||||||
|
|
||||||
|
vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() }));
|
||||||
|
vi.mock('$lib/errors', () => ({ getErrorMessage: (code: string) => code ?? 'Unknown error' }));
|
||||||
|
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
|
||||||
|
const writeUser = { groups: [{ permissions: ['WRITE_ALL'] }] };
|
||||||
|
const readUser = { groups: [{ permissions: ['READ_ALL'] }] };
|
||||||
|
|
||||||
|
function makeUrl(params: Record<string, string> = {}): URL {
|
||||||
|
const url = new URL('http://x/korrespondenz');
|
||||||
|
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockApi(calls: { ok: boolean; data?: unknown; status?: number }[]) {
|
||||||
|
const GET = vi.fn();
|
||||||
|
for (const call of calls) {
|
||||||
|
GET.mockResolvedValueOnce({
|
||||||
|
response: { ok: call.ok, status: call.status ?? (call.ok ? 200 : 500) },
|
||||||
|
data: call.data,
|
||||||
|
error: call.ok ? undefined : { code: 'INTERNAL_ERROR' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ GET } as ReturnType<typeof createApiClient>);
|
||||||
|
return GET;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
// ─── No senderId ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('korrespondenz load — no senderId', () => {
|
||||||
|
it('returns empty documents without calling the conversation endpoint', async () => {
|
||||||
|
const GET = mockApi([]);
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
url: makeUrl(),
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
|
locals: { user: readUser }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.documents).toEqual([]);
|
||||||
|
expect(GET).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── With senderId, no receiverId ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('korrespondenz load — senderId set, no receiverId', () => {
|
||||||
|
it('calls the conversation endpoint and the sender person endpoint', async () => {
|
||||||
|
const docs = [{ id: 'd1', title: 'Testbrief' }];
|
||||||
|
const GET = mockApi([
|
||||||
|
{ ok: true, data: docs },
|
||||||
|
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
url: makeUrl({ senderId: 'p1' }),
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
|
locals: { user: readUser }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.documents).toEqual(docs);
|
||||||
|
expect(result.initialValues.senderName).toBe('Hans Müller');
|
||||||
|
expect(result.initialValues.receiverName).toBe('');
|
||||||
|
expect(GET).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── With senderId and receiverId ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('korrespondenz load — senderId and receiverId set', () => {
|
||||||
|
it('calls conversation, sender person, and receiver person endpoints', async () => {
|
||||||
|
const GET = mockApi([
|
||||||
|
{ ok: true, data: [] },
|
||||||
|
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } },
|
||||||
|
{ ok: true, data: { firstName: 'Anna', lastName: 'Schmidt' } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
url: makeUrl({ senderId: 'p1', receiverId: 'p2' }),
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
|
locals: { user: readUser }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.initialValues.senderName).toBe('Hans Müller');
|
||||||
|
expect(result.initialValues.receiverName).toBe('Anna Schmidt');
|
||||||
|
expect(GET).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── canWrite derivation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('korrespondenz load — canWrite', () => {
|
||||||
|
it('derives canWrite true from WRITE_ALL permission', async () => {
|
||||||
|
mockApi([
|
||||||
|
{ ok: true, data: [] },
|
||||||
|
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
url: makeUrl({ senderId: 'p1' }),
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
|
locals: { user: writeUser }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.canWrite).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives canWrite false when user lacks WRITE_ALL', async () => {
|
||||||
|
mockApi([
|
||||||
|
{ ok: true, data: [] },
|
||||||
|
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
url: makeUrl({ senderId: 'p1' }),
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
|
locals: { user: readUser }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.canWrite).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Backend error propagation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('korrespondenz load — backend error', () => {
|
||||||
|
it('throws when the conversation endpoint returns non-ok', async () => {
|
||||||
|
mockApi([
|
||||||
|
{ ok: false, status: 500 },
|
||||||
|
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
load({
|
||||||
|
url: makeUrl({ senderId: 'p1' }),
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
|
locals: { user: readUser }
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ status: 500 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,8 +18,15 @@ const baseData = {
|
|||||||
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
|
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const withSender = {
|
||||||
|
...baseData,
|
||||||
|
initialValues: { senderName: 'Hans Müller', receiverName: '' },
|
||||||
|
filters: { ...baseData.filters, senderId: 'p1' }
|
||||||
|
};
|
||||||
|
|
||||||
const withPersons = {
|
const withPersons = {
|
||||||
...baseData,
|
...baseData,
|
||||||
|
initialValues: { senderName: 'Hans Müller', receiverName: 'Anna Schmidt' },
|
||||||
filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
|
filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,6 +37,7 @@ const makeDoc = (overrides: Record<string, unknown> = {}) => ({
|
|||||||
status: 'UPLOADED' as const,
|
status: 'UPLOADED' as const,
|
||||||
documentDate: '1923-04-12',
|
documentDate: '1923-04-12',
|
||||||
location: 'Berlin',
|
location: 'Berlin',
|
||||||
|
metadataComplete: false,
|
||||||
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
|
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
|
||||||
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
|
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -45,41 +53,126 @@ const withDocs = {
|
|||||||
documents: [makeDoc()]
|
documents: [makeDoc()]
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Empty state ──────────────────────────────────────────────────────────────
|
// ─── Empty state (no senderId) ────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Conversations page – empty state', () => {
|
describe('Korrespondenz page – empty state', () => {
|
||||||
it('shows the "select two persons" prompt when no persons are selected', async () => {
|
it('shows the search heading when no person is selected', async () => {
|
||||||
render(Page, { data: baseData });
|
render(Page, { data: baseData });
|
||||||
await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument();
|
await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides the swap button when no persons are selected', async () => {
|
it('shows the empty-search button', async () => {
|
||||||
render(Page, { data: baseData });
|
render(Page, { data: baseData });
|
||||||
// Button is always in the DOM (holds grid column width on desktop) but made invisible
|
await expect.element(page.getByTestId('conv-empty-search')).toBeInTheDocument();
|
||||||
await expect.element(page.getByTestId('conv-swap-btn')).toHaveClass('invisible');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show the new document link when no persons are selected', async () => {
|
it('does not show the new document link when no person is selected', async () => {
|
||||||
render(Page, { data: baseData });
|
render(Page, { data: baseData });
|
||||||
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
|
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not show a year divider when no person is selected', async () => {
|
||||||
|
render(Page, { data: baseData });
|
||||||
|
await expect.element(page.getByTestId('year-divider')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Recent persons chips ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Korrespondenz page – recent persons', () => {
|
||||||
|
it('shows recent person chips from localStorage', async () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'korrespondenz_recent_persons',
|
||||||
|
JSON.stringify([{ id: 'r1', name: 'Clara Braun' }])
|
||||||
|
);
|
||||||
|
render(Page, { data: baseData });
|
||||||
|
await expect.element(page.getByText('Clara Braun')).toBeInTheDocument();
|
||||||
|
localStorage.removeItem('korrespondenz_recent_persons');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not crash when localStorage contains corrupt JSON', async () => {
|
||||||
|
localStorage.setItem('korrespondenz_recent_persons', '}{not valid json');
|
||||||
|
render(Page, { data: baseData });
|
||||||
|
// Empty state heading is still shown — no chip list crash
|
||||||
|
await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
|
||||||
|
localStorage.removeItem('korrespondenz_recent_persons');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Single-person hint bar ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Korrespondenz page – single-person hint bar', () => {
|
||||||
|
it('shows hint bar when only senderId is set', async () => {
|
||||||
|
render(Page, { data: withSender });
|
||||||
|
await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show hint bar when both persons are set', async () => {
|
||||||
|
render(Page, { data: { ...withPersons, documents: [makeDoc()] } });
|
||||||
|
await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show hint bar when no person is set', async () => {
|
||||||
|
render(Page, { data: baseData });
|
||||||
|
await expect.element(page.getByText(/Alle Briefe von/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Filter controls disabled state ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Korrespondenz page – filter strip Row 2 disabled state', () => {
|
||||||
|
it('renders filter controls with aria-disabled when no senderId', async () => {
|
||||||
|
render(Page, { data: baseData });
|
||||||
|
const strip = document.querySelector('[aria-disabled="true"]');
|
||||||
|
expect(strip).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filter controls are not aria-disabled when senderId is set', async () => {
|
||||||
|
render(Page, { data: withSender });
|
||||||
|
const strip = document.querySelector('[aria-disabled="false"]');
|
||||||
|
expect(strip).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Strip letter count ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Korrespondenz page – strip letter count', () => {
|
||||||
|
it('shows 0 Briefe when senderId is set but no documents', async () => {
|
||||||
|
render(Page, { data: withSender });
|
||||||
|
await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('0 Briefe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows correct count when documents are loaded', async () => {
|
||||||
|
render(Page, { data: { ...withPersons, documents: [makeDoc()] } });
|
||||||
|
await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('1 Briefe');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── No results ───────────────────────────────────────────────────────────────
|
// ─── No results ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Conversations page – no results', () => {
|
describe('Korrespondenz page – no results', () => {
|
||||||
it('shows "no documents found" when both persons are selected but there are no documents', async () => {
|
it('shows "no documents found" when a person is selected but there are no documents', async () => {
|
||||||
render(Page, { data: withPersons });
|
render(Page, { data: withSender });
|
||||||
await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument();
|
await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Swap button ──────────────────────────────────────────────────────────────
|
// ─── Swap button ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Conversations page – swap button', () => {
|
describe('Korrespondenz page – swap button', () => {
|
||||||
it('shows the swap button when both persons are selected', async () => {
|
it('swap button is invisible when only one person is set', async () => {
|
||||||
|
render(Page, { data: withSender });
|
||||||
|
const btn = document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]');
|
||||||
|
expect(btn).not.toBeNull();
|
||||||
|
// opacity-0 is applied via class when swapVisible is false
|
||||||
|
expect(btn!.className).toMatch(/opacity-0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swap button is visible when both persons are set', async () => {
|
||||||
render(Page, { data: withPersons });
|
render(Page, { data: withPersons });
|
||||||
await expect.element(page.getByTestId('conv-swap-btn')).not.toHaveClass('invisible');
|
const btn = document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]');
|
||||||
|
expect(btn).not.toBeNull();
|
||||||
|
expect(btn!.className).not.toMatch(/opacity-0/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls goto with swapped sender and receiver when clicked', async () => {
|
it('calls goto with swapped sender and receiver when clicked', async () => {
|
||||||
@@ -92,28 +185,9 @@ describe('Conversations page – swap button', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Conversations page – summary', () => {
|
|
||||||
it('shows document count and year range when documents are loaded', async () => {
|
|
||||||
const data = {
|
|
||||||
...withPersons,
|
|
||||||
documents: [
|
|
||||||
makeDoc({ documentDate: '1923-04-12' }),
|
|
||||||
makeDoc({ id: 'd2', documentDate: '1965-08-03' })
|
|
||||||
]
|
|
||||||
};
|
|
||||||
render(Page, { data });
|
|
||||||
const summary = page.getByTestId('conv-summary');
|
|
||||||
await expect.element(summary).toHaveTextContent('2');
|
|
||||||
await expect.element(summary).toHaveTextContent('1923');
|
|
||||||
await expect.element(summary).toHaveTextContent('1965');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Year dividers ────────────────────────────────────────────────────────────
|
// ─── Year dividers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Conversations page – year dividers', () => {
|
describe('Korrespondenz page – year dividers', () => {
|
||||||
it('renders a year divider for the first document', async () => {
|
it('renders a year divider for the first document', async () => {
|
||||||
render(Page, { data: withDocs });
|
render(Page, { data: withDocs });
|
||||||
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
||||||
@@ -141,7 +215,6 @@ describe('Conversations page – year dividers', () => {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
render(Page, { data });
|
render(Page, { data });
|
||||||
// Only one divider for 1923; 1965 divider should not appear
|
|
||||||
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
||||||
await expect.element(page.getByTestId('year-divider').nth(1)).not.toBeInTheDocument();
|
await expect.element(page.getByTestId('year-divider').nth(1)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -149,12 +222,21 @@ describe('Conversations page – year dividers', () => {
|
|||||||
|
|
||||||
// ─── New document link ────────────────────────────────────────────────────────
|
// ─── New document link ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Conversations page – new document link', () => {
|
describe('Korrespondenz page – new document link', () => {
|
||||||
it('shows the link with correct href for a write user', async () => {
|
it('shows the link with correct href for a write user (bilateral)', async () => {
|
||||||
render(Page, { data: { ...withDocs, canWrite: true } });
|
render(Page, { data: { ...withDocs, canWrite: true } });
|
||||||
const link = page.getByTestId('conv-new-doc-link');
|
const link = page.getByTestId('conv-new-doc-link');
|
||||||
await expect.element(link).toBeInTheDocument();
|
await expect.element(link).toBeInTheDocument();
|
||||||
await expect.element(link).toHaveAttribute('href', '/documents/new?senderId=p1&receiverId=p2');
|
await expect.element(link).toHaveAttribute('href', expect.stringContaining('senderId=p1'));
|
||||||
|
await expect.element(link).toHaveAttribute('href', expect.stringContaining('receiverId=p2'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the link with correct href for single-person mode', async () => {
|
||||||
|
render(Page, { data: { ...withSender, documents: [makeDoc()], canWrite: true } });
|
||||||
|
const link = page.getByTestId('conv-new-doc-link');
|
||||||
|
await expect.element(link).toBeInTheDocument();
|
||||||
|
await expect.element(link).toHaveAttribute('href', expect.stringContaining('senderId=p1'));
|
||||||
|
await expect.element(link).not.toHaveAttribute('href', expect.stringContaining('receiverId'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides the link for a read-only user', async () => {
|
it('hides the link for a read-only user', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user