Compare commits

..

14 Commits

Author SHA1 Message Date
Marcel
6b15ea8b1f style: standardise link hover underline (2px, offset-4, accent) globally
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m35s
CI / Backend Unit Tests (pull_request) Failing after 2m22s
CI / E2E Tests (pull_request) Failing after 1h23m35s
Move text-decoration-thickness/underline-offset into the global a:hover
base rule so every link that shows an underline on hover gets identical
treatment: 2px thick, 4px offset, accent colour.

Remove the now-redundant per-component decoration-brand-mint / decoration-
accent / decoration-2 / underline-offset-{2,4} utilities from DocumentList,
enrich, persons, PersonDocumentList, and PanelMetadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:35:06 +02:00
Marcel
b1f82d91d2 style: teal accent underline on link hover globally
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m33s
CI / Backend Unit Tests (pull_request) Failing after 2m23s
CI / E2E Tests (pull_request) Failing after 1h23m39s
Any link that renders an underline on hover now gets the brand accent
colour (--c-accent) as its decoration colour. Links that suppress
underlines (nav, back-links, button-style anchors) are unaffected.
Dark mode already maps --c-accent to the stronger turquoise (#00c7b1).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:31:42 +02:00
Marcel
adba3058b4 style(#145): bump dashboard widget content links to text-lg
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m38s
CI / Backend Unit Tests (pull_request) Failing after 2m20s
CI / E2E Tests (pull_request) Failing after 1h16m46s
Box content links (document titles, actor names) raised from text-sm to
text-lg for improved readability and touch target size. "Show all" stays
at text-sm to maintain hierarchy — box links are the primary action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:18:13 +02:00
Marcel
5bdd26c792 fix(#145): address PR review — full-table scan, a11y, grid, tests
- DocumentService.getRecentActivity: replace findAll(Sort)+stream().limit()
  with findAll(PageRequest) so LIMIT is pushed to the database
- +page.svelte: collapse two-column grid to single column when mentions is empty
- DashboardNeedsMetadata: raise "show all" link from text-xs (12px) to text-sm
  (14px) and add hover:underline for WCAG 1.4.1
- DashboardRecentDocuments: add comment explaining why T12:00:00 noon-anchor
  is absent (updatedAt is a full ISO datetime, not a date-only string)
- DocumentServiceTest: update getRecentActivity tests to assert PageRequest
  usage instead of findAll(Sort)
- DocumentRepositoryTest: add @DataJpaTest verifying findAll(PageRequest)
  returns only size rows, not the full table
- DocumentControllerTest: add test for default size=5 when param is omitted
- NotificationServiceTest: add test documenting that type+read=true falls
  through to the type-only query (intentional)
- page.server.spec.ts: replace stale tests with full dashboard-mode coverage
- DashboardMentions.svelte.spec.ts: add tests for REPLY type and absent documentId
- DashboardResumeStrip.svelte.spec.ts: add corrupt localStorage test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:11:12 +02:00
Marcel
7eda0aefcc feat(e2e): seed admin notifications + resume strip for dashboard proofshots
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 2m34s
CI / Backend Unit Tests (pull_request) Failing after 2m49s
CI / E2E Tests (pull_request) Failing after 1h24m49s
- captureProofshots() now accepts an optional setup(page) callback that
  runs before each screenshot's page.goto(), so localStorage can be
  injected reliably without loading a backend-dependent page
- dashboard-screenshots.spec.ts seeds 2 notifications (MENTION + REPLY)
  for admin via direct DB insert in beforeAll, cleans up in afterAll
- localStorage.familienarchiv.lastVisited injected directly via
  page.evaluate() — no fragile document page navigation needed
- Updated screenshots committed (all 6 now show all 4 widgets)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:46:39 +02:00
Marcel
3e76ef5281 fix(#152): disable open-in-view to prevent HikariPool exhaustion
spring.jpa.open-in-view=true (the default) holds a DB connection open for
the entire HTTP request lifecycle. Under concurrent dashboard API calls
(Promise.allSettled fires 3 at once), the pool of 10 is exhausted and the
backend crashes with connection timeout errors.

Setting open-in-view=false releases connections as soon as each
@Transactional method completes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:44:30 +02:00
Marcel
2171c3702a feat(#145): switch dashboard to show last-activity documents
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m57s
CI / Backend Unit Tests (pull_request) Failing after 2m19s
CI / E2E Tests (pull_request) Failing after 3h14m27s
Replace recent-by-creation fetch with GET /api/documents/recent-activity
(sorted by updatedAt) in the dashboard. Update DashboardRecentDocuments
component to use doc.updatedAt, update i18n heading to "Zuletzt aktiv" /
"Recent Activity" / "Actividad reciente", and regenerate API types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:30:08 +02:00
Marcel
6976daa910 feat(#145): add recent-activity endpoint sorted by updatedAt
Add GET /api/documents/recent-activity?size=N endpoint that returns
the N most recently updated documents sorted by updatedAt DESC.
Includes TDD: failing tests written first, then production code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:29:34 +02:00
Marcel
dc487e2f97 refactor(e2e): extract reusable captureProofshots helper
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 2m0s
CI / Backend Unit Tests (pull_request) Failing after 2m25s
CI / E2E Tests (pull_request) Failing after 3h14m4s
Any future feature spec now just calls:
  captureProofshots('/my-route', 'feature-name')

to get 6 screenshots (3 viewports × 2 themes) saved to
proofshot-artifacts/{feature-name}/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:01:49 +02:00
Marcel
698a0fb15e docs(#145): add dashboard proofshots (3 viewports × 2 themes)
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 2m17s
CI / Backend Unit Tests (pull_request) Failing after 2m24s
CI / E2E Tests (pull_request) Failing after 3h3m53s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:32:58 +02:00
Marcel
a7b0bd96d4 test(#145): add Playwright screenshot spec for dashboard (3 viewports × 2 themes)
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:30:39 +02:00
Marcel
7734ce7bae fix(#145): deep-link notifications; show createdAt in recent docs
- Notification widget builds full link with ?commentId= and
  &annotationId= params, matching the bell notification behaviour
- Recent docs widget shows createdAt (upload date) instead of
  documentDate (the date on the original document)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:03:36 +02:00
Marcel
c8da2224f8 feat(#145): internationalise dashboard widget strings (de/en/es)
Replace all hardcoded German strings in dashboard components with
Paraglide translation keys. Date locale uses getLocale() instead
of the hardcoded 'de-DE'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 09:57:14 +02:00
Marcel
08f3f92167 fix(#145): dashboard notification widget shows all recent notifications
- Add type-only filter to notification repo/service (previously only
  worked with type+read=false together)
- Dashboard widget now fetches all recent notifications (mentions +
  replies, both read and unread) instead of unread mentions only
- Update component heading and show type label per row

Root cause: Berit's mentions were read=true, so the unread-only filter
returned 0 results. The recent docs widget had no REVIEWED documents
because 'marking ready' sets metadata_complete, not status=REVIEWED.
Recent docs now shows all uploads without a status filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 09:41:28 +02:00
39 changed files with 566 additions and 114 deletions

View File

@@ -11,6 +11,7 @@ import java.util.UUID;
import io.swagger.v3.oas.annotations.Parameter;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
@@ -167,7 +168,7 @@ public class DocumentController {
@GetMapping("/incomplete")
public List<IncompleteDocumentDTO> getIncomplete(
@RequestParam(defaultValue = "10") int size) {
@Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "10") int size) {
return documentService.findIncompleteDocuments(size);
}
@@ -178,6 +179,12 @@ public class DocumentController {
.orElse(ResponseEntity.noContent().build());
}
@GetMapping("/recent-activity")
public ResponseEntity<List<Document>> getRecentActivity(
@RequestParam(defaultValue = "5") int size) {
return ResponseEntity.ok(documentService.getRecentActivity(size));
}
@GetMapping("/search")
public ResponseEntity<List<Document>> search(
@RequestParam(required = false) String q,
@@ -186,7 +193,7 @@ public class DocumentController {
@RequestParam(required = false) UUID senderId,
@RequestParam(required = false) UUID receiverId,
@RequestParam(required = false, name = "tag") List<String> tags,
@RequestParam(required = false) DocumentStatus status) {
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status) {
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, status));
}

View File

@@ -15,6 +15,9 @@ public interface NotificationRepository extends JpaRepository<Notification, UUID
Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable);
Page<Notification> findByRecipientIdAndTypeOrderByCreatedAtDesc(
UUID recipientId, NotificationType type, Pageable pageable);
Page<Notification> findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
UUID recipientId, NotificationType type, Pageable pageable);

View File

@@ -260,6 +260,13 @@ public class DocumentService {
return documentRepository.save(doc);
}
// 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC)
public List<Document> getRecentActivity(int size) {
return documentRepository.findAll(
PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt"))
).getContent();
}
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, DocumentStatus status) {
Specification<Document> spec = Specification.where(hasText(text))

View File

@@ -98,6 +98,10 @@ public class NotificationService {
return notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable)
.map(this::toDTO);
}
if (type != null) {
return notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(userId, type, pageable)
.map(this::toDTO);
}
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable)
.map(this::toDTO);
}

View File

@@ -12,6 +12,7 @@ spring:
enabled: false # Managed explicitly via FlywayConfig bean
jpa:
open-in-view: false # Prevents holding DB connections for the full HTTP request lifecycle
hibernate:
ddl-auto: none
properties:

View File

@@ -406,6 +406,38 @@ class DocumentControllerTest {
.andExpect(status().isNoContent());
}
// ─── GET /api/documents/recent-activity ──────────────────────────────────
@Test
void getRecentActivity_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/recent-activity"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getRecentActivity_returnsOkWithDocuments() throws Exception {
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
when(documentService.getRecentActivity(5)).thenReturn(List.of(doc1, doc2));
mockMvc.perform(get("/api/documents/recent-activity").param("size", "5"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Alpha"))
.andExpect(jsonPath("$[1].title").value("Beta"));
}
@Test
@WithMockUser
void getRecentActivity_appliesDefaultSizeOfFive_whenSizeParamOmitted() throws Exception {
when(documentService.getRecentActivity(5)).thenReturn(List.of());
mockMvc.perform(get("/api/documents/recent-activity"))
.andExpect(status().isOk());
verify(documentService).getRecentActivity(5);
}
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
@Test

View File

@@ -152,6 +152,23 @@ class DocumentRepositoryTest {
assertThat(documentRepository.countByMetadataCompleteFalse()).isEqualTo(1);
}
// ─── findAll (PageRequest) — recent activity ──────────────────────────────
@Test
void findAll_withPageRequest_returnsOnlySizeRows_notFullTable() {
for (int i = 0; i < 10; i++) {
documentRepository.save(Document.builder()
.title("Doc " + i).originalFilename("doc" + i + ".pdf")
.status(DocumentStatus.PLACEHOLDER).build());
}
Page<Document> result = documentRepository.findAll(
PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "updatedAt")));
assertThat(result.getContent()).hasSize(3);
assertThat(result.getTotalElements()).isEqualTo(10);
}
// ─── findByMetadataCompleteFalse (Pageable) ───────────────────────────────
@Test

View File

@@ -79,6 +79,24 @@ class NotificationRepositoryTest {
assertThat(result.getTotalElements()).isEqualTo(5);
}
// ─── findByRecipientIdAndType (without read filter) ──────────────────────
@Test
void findByType_returnsBothReadAndUnreadMentions() {
notificationRepository.save(mention(userA, false)); // unread
notificationRepository.save(mention(userA, true)); // read — should also be included
notificationRepository.save(reply(userA, false)); // REPLY — excluded
notificationRepository.save(mention(userB, false)); // different user — excluded
Page<Notification> result = notificationRepository
.findByRecipientIdAndTypeOrderByCreatedAtDesc(
userA.getId(), NotificationType.MENTION, Pageable.ofSize(10));
assertThat(result.getContent()).hasSize(2);
assertThat(result.getContent()).allMatch(n -> n.getType() == NotificationType.MENTION);
assertThat(result.getContent()).allMatch(n -> n.getRecipient().getId().equals(userA.getId()));
}
// ─── helpers ─────────────────────────────────────────────────────────────
private Notification mention(AppUser recipient, boolean read) {

View File

@@ -1213,4 +1213,35 @@ class DocumentServiceTest {
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
}
// ─── getRecentActivity ────────────────────────────────────────────────────
@Test
void getRecentActivity_returnsMostRecentlyUpdatedDocuments() {
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Oldest").build();
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Middle").build();
Document doc3 = Document.builder().id(UUID.randomUUID()).title("Newest").build();
Page<Document> page = new PageImpl<>(List.of(doc3, doc2));
when(documentRepository.findAll(any(Pageable.class))).thenReturn(page);
List<Document> result = documentService.getRecentActivity(2);
assertThat(result).hasSize(2);
assertThat(result).containsExactly(doc3, doc2);
}
@Test
void getRecentActivity_usesPageRequestWithSizeLimit_notFindAll() {
Page<Document> page = new PageImpl<>(List.of());
when(documentRepository.findAll(any(Pageable.class))).thenReturn(page);
documentService.getRecentActivity(3);
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
verify(documentRepository).findAll(captor.capture());
assertThat(captor.getValue().getPageSize()).isEqualTo(3);
assertThat(captor.getValue().getSort())
.isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt"));
}
}

View File

@@ -386,6 +386,39 @@ class NotificationServiceTest {
verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any());
}
@Test
void getNotifications_withTypeOnly_usesTypeFilteredRepoMethod() {
when(notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(
eq(userA.getId()), eq(NotificationType.MENTION), any()))
.thenReturn(Page.empty());
notificationService.getNotifications(userA.getId(), NotificationType.MENTION, null, Pageable.ofSize(5));
verify(notificationRepository).findByRecipientIdAndTypeOrderByCreatedAtDesc(
eq(userA.getId()), eq(NotificationType.MENTION), any());
verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any());
verify(notificationRepository, never())
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any());
}
@Test
void getNotifications_withTypeAndReadTrue_fallsBackToTypeOnlyQuery() {
// read=true with a type filter falls through to the type-only branch —
// it returns all notifications of that type (both read and unread).
// The read=true filter is intentionally not supported on the backend;
// callers that need only-read results must filter client-side.
when(notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(
eq(userA.getId()), eq(NotificationType.MENTION), any()))
.thenReturn(Page.empty());
notificationService.getNotifications(userA.getId(), NotificationType.MENTION, true, Pageable.ofSize(5));
verify(notificationRepository).findByRecipientIdAndTypeOrderByCreatedAtDesc(
eq(userA.getId()), eq(NotificationType.MENTION), any());
verify(notificationRepository, never())
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any());
}
// ─── private helpers ──────────────────────────────────────────────────────
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) {

View File

@@ -0,0 +1,62 @@
/**
* Dashboard proofshots — seeds the admin account with test data so every
* widget is visible, then captures 6 screenshots (3 viewports × 2 themes).
*
* Seeded data is removed in afterAll so it doesn't pollute other tests.
*/
import { test } from '@playwright/test';
import { execSync } from 'child_process';
import { captureProofshots } from './proofshots';
// A real document that exists in the dev DB (most recently updated)
const SEED_DOC_ID = '24580ce9-9765-40b1-ac59-b0ab15160ce0';
const SEED_DOC_TITLE = 'Brief aus dem Krieg';
// Real comment IDs used as reference_id for deep-linking
const COMMENT_IDS = [
'46c5171f-1721-4085-a7ed-1eef7b4effb8',
'a09cefe4-ddf8-47fa-addc-5c582183b459'
];
const psql = (sql: string) =>
execSync(
`docker exec archive-db psql -U archive_user family_archive_db -c "${sql.replace(/"/g, '\\"')}"`
);
test.beforeAll(() => {
// Insert a MENTION and a REPLY notification for the admin user so the
// notifications widget is populated in the screenshots.
psql(`
INSERT INTO notifications (recipient_id, type, document_id, reference_id, read, actor_name)
SELECT id, 'MENTION', '${SEED_DOC_ID}', '${COMMENT_IDS[0]}', false, 'Berit Hoffmann'
FROM users WHERE username = 'admin';
INSERT INTO notifications (recipient_id, type, document_id, reference_id, read, actor_name)
SELECT id, 'REPLY', '${SEED_DOC_ID}', '${COMMENT_IDS[1]}', false, 'Marcel Raddatz'
FROM users WHERE username = 'admin';
`);
});
test.afterAll(() => {
// Remove only the seeded rows (identified by the sentinel actor names)
psql(`
DELETE FROM notifications
WHERE actor_name IN ('Berit Hoffmann', 'Marcel Raddatz')
AND recipient_id = (SELECT id FROM users WHERE username = 'admin');
`);
});
captureProofshots('/', 'dashboard', {
setup: async (page) => {
// Navigate to '/' first so the browser has an origin for localStorage,
// then inject the lastVisited entry directly — no document page load needed.
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
await page.evaluate(
({ id, title }) => {
localStorage.setItem('familienarchiv.lastVisited', JSON.stringify({ id, title }));
},
{ id: SEED_DOC_ID, title: SEED_DOC_TITLE }
);
}
});

View File

@@ -0,0 +1,85 @@
/**
* Shared proofshot helper for Playwright.
*
* Basic usage:
* import { captureProofshots } from './proofshots';
* captureProofshots('/persons', 'persons');
*
* With per-test setup (e.g. seed localStorage before navigation):
* captureProofshots('/persons', 'persons', {
* setup: async (page) => {
* await page.goto('/persons/some-id'); // populates any localStorage state
* }
* });
*
* The setup callback runs before each screenshot's page.goto(url), so any
* localStorage values it writes persist into the main navigation.
*
* Screenshots are saved to proofshot-artifacts/{featureName}/.
*/
import { type Page, test } from '@playwright/test';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const viewports = [
{ name: 'mobile', width: 390, height: 844 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1440, height: 900 }
];
interface ProofshotOptions {
/**
* Optional async callback that runs before each screenshot's page.goto(url).
* Use it to seed localStorage, visit a prerequisite page, etc.
*/
setup?: (page: Page) => Promise<void>;
}
/**
* Registers Playwright tests that navigate to `url`, apply each theme,
* and capture full-page screenshots at all standard viewports.
*
* @param url The path to screenshot (e.g. '/', '/persons', '/admin')
* @param featureName Used as the output directory name and screenshot file prefix
* @param options Optional setup callback and other options
*/
export function captureProofshots(
url: string,
featureName: string,
options?: ProofshotOptions
): void {
const outDir = path.join(__dirname, '../../proofshot-artifacts', featureName);
fs.mkdirSync(outDir, { recursive: true });
for (const vp of viewports) {
for (const theme of ['light', 'dark'] as const) {
test(`${featureName} ${vp.name} ${theme}`, async ({ page }) => {
// Run optional setup before main navigation (e.g. seed localStorage)
if (options?.setup) {
await options.setup(page);
}
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto(url);
// Apply theme via data-theme attribute and localStorage
await page.evaluate((t) => {
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('theme', t);
}, theme);
// 'networkidle' is unreliable in SvelteKit dev mode due to the HMR WebSocket.
await page.waitForLoadState('domcontentloaded');
await page.waitForSelector('main', { state: 'visible' });
await page.screenshot({
path: path.join(outDir, `${featureName}-${vp.name}-${theme}.png`),
fullPage: true
});
});
}
}
}

View File

@@ -312,5 +312,13 @@
"page_title_persons": "Personen",
"page_title_admin": "Administration",
"page_title_login": "Anmelden",
"page_title_error": "Fehler Familienarchiv"
"page_title_error": "Fehler Familienarchiv",
"dashboard_notifications_heading": "Benachrichtigungen",
"dashboard_notification_mentioned": "erwähnt Sie",
"dashboard_notification_replied": "hat geantwortet",
"dashboard_needs_metadata_heading": "Metadaten fehlen",
"dashboard_needs_metadata_show_all": "Alle anzeigen",
"dashboard_recent_heading": "Zuletzt aktiv",
"dashboard_resume_label": "Zuletzt geöffnet:",
"dashboard_resume_fallback": "Unbekanntes Dokument"
}

View File

@@ -312,5 +312,13 @@
"page_title_persons": "Persons",
"page_title_admin": "Administration",
"page_title_login": "Sign in",
"page_title_error": "Error Family Archive"
"page_title_error": "Error Family Archive",
"dashboard_notifications_heading": "Notifications",
"dashboard_notification_mentioned": "mentioned you",
"dashboard_notification_replied": "replied",
"dashboard_needs_metadata_heading": "Missing Metadata",
"dashboard_needs_metadata_show_all": "Show all",
"dashboard_recent_heading": "Recent Activity",
"dashboard_resume_label": "Last opened:",
"dashboard_resume_fallback": "Unknown document"
}

View File

@@ -312,5 +312,13 @@
"page_title_persons": "Personas",
"page_title_admin": "Administración",
"page_title_login": "Iniciar sesión",
"page_title_error": "Error Archivo familiar"
"page_title_error": "Error Archivo familiar",
"dashboard_notifications_heading": "Notificaciones",
"dashboard_notification_mentioned": "te mencionó",
"dashboard_notification_replied": "respondió",
"dashboard_needs_metadata_heading": "Metadatos incompletos",
"dashboard_needs_metadata_show_all": "Ver todos",
"dashboard_recent_heading": "Actividad reciente",
"dashboard_resume_label": "Último abierto:",
"dashboard_resume_fallback": "Documento desconocido"
}

View File

@@ -1,8 +1,12 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
type NotificationDTO = {
id: string;
type: 'REPLY' | 'MENTION';
documentId?: string;
referenceId?: string;
annotationId?: string;
read: boolean;
createdAt: string;
actorName?: string;
@@ -18,19 +22,26 @@ let { mentions }: Props = $props();
{#if mentions.length > 0}
<div data-testid="dashboard-mentions" class="rounded-sm border border-line bg-surface p-6">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
Erwähnungen
{m.dashboard_notifications_heading()}
</h2>
{#each mentions as mention (mention.id)}
<div class="flex items-center gap-3 border-b border-line py-2 last:border-0">
{#if mention.documentId}
<a
href="/documents/{mention.documentId}"
class="font-serif text-sm text-ink hover:text-ink-2"
href={mention.annotationId
? `/documents/${mention.documentId}?commentId=${mention.referenceId}&annotationId=${mention.annotationId}`
: `/documents/${mention.documentId}?commentId=${mention.referenceId}`}
class="font-serif text-lg text-ink hover:text-ink-2"
>
{mention.actorName ?? ''}
{#if mention.type === 'MENTION'}<span class="ml-1 font-sans text-xs text-gray-400"
>{m.dashboard_notification_mentioned()}</span
>{:else}<span class="ml-1 font-sans text-xs text-gray-400"
>{m.dashboard_notification_replied()}</span
>{/if}
</a>
{:else}
<span class="font-serif text-sm text-ink">{mention.actorName ?? ''}</span>
<span class="font-serif text-lg text-ink">{mention.actorName ?? ''}</span>
{/if}
</div>
{/each}

View File

@@ -10,6 +10,8 @@ type NotificationDTO = {
id: string;
type: 'REPLY' | 'MENTION';
documentId?: string;
referenceId?: string;
annotationId?: string;
read: boolean;
createdAt: string;
actorName?: string;
@@ -20,6 +22,7 @@ function makeMention(overrides: Partial<NotificationDTO> = {}): NotificationDTO
id: 'notif-1',
type: 'MENTION',
documentId: 'doc-abc',
referenceId: 'comment-xyz',
read: false,
createdAt: '2026-01-15T10:00:00Z',
actorName: 'Anna Schmidt',
@@ -40,19 +43,43 @@ describe('DashboardMentions', () => {
await expect.element(widget).toBeInTheDocument();
});
it('renders one row per mention with link to document', async () => {
const mentions = [
makeMention({ id: 'n1', documentId: 'doc-1', actorName: 'Anna' }),
makeMention({ id: 'n2', documentId: 'doc-2', actorName: 'Bob' })
];
render(DashboardMentions, { mentions });
const links = page.getByRole('link');
await expect.element(links.nth(0)).toHaveAttribute('href', '/documents/doc-1');
await expect.element(links.nth(1)).toHaveAttribute('href', '/documents/doc-2');
it('builds link with commentId param when no annotationId', async () => {
render(DashboardMentions, {
mentions: [makeMention({ documentId: 'doc-1', referenceId: 'cmt-1' })]
});
const link = page.getByRole('link');
await expect.element(link).toHaveAttribute('href', '/documents/doc-1?commentId=cmt-1');
});
it('builds link with commentId and annotationId when annotationId is present', async () => {
render(DashboardMentions, {
mentions: [makeMention({ documentId: 'doc-2', referenceId: 'cmt-2', annotationId: 'ann-9' })]
});
const link = page.getByRole('link');
await expect
.element(link)
.toHaveAttribute('href', '/documents/doc-2?commentId=cmt-2&annotationId=ann-9');
});
it('shows actor name in each row', async () => {
render(DashboardMentions, { mentions: [makeMention({ actorName: 'Maria Müller' })] });
await expect.element(page.getByText('Maria Müller')).toBeInTheDocument();
});
it('shows "replied" label for REPLY type', async () => {
render(DashboardMentions, { mentions: [makeMention({ type: 'REPLY' })] });
const widget = page.getByTestId('dashboard-mentions');
await expect.element(widget).toBeInTheDocument();
const link = page.getByRole('link');
await expect.element(link).toBeInTheDocument();
});
it('renders a span instead of a link when documentId is absent', async () => {
render(DashboardMentions, {
mentions: [makeMention({ documentId: undefined, actorName: 'Lena Bauer' })]
});
await expect.element(page.getByText('Lena Bauer')).toBeInTheDocument();
const links = page.getByRole('link');
await expect.element(links).not.toBeInTheDocument();
});
});

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
type IncompleteDocumentDTO = {
id: string;
title: string;
@@ -14,20 +16,22 @@ let { incompleteDocs }: Props = $props();
{#if incompleteDocs.length > 0}
<div data-testid="dashboard-needs-metadata" class="rounded-sm border border-line bg-surface p-6">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
Metadaten fehlen
{m.dashboard_needs_metadata_heading()}
</h2>
{#each incompleteDocs as doc (doc.id)}
<div class="flex items-center border-b border-line py-2 last:border-0">
<a
href="/enrich/{doc.id}"
class="font-serif text-sm text-ink hover:text-ink-2 hover:underline"
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
>
{doc.title}
</a>
</div>
{/each}
<div class="mt-4">
<a href="/enrich" class="font-sans text-xs text-ink-2 hover:text-ink"> Alle anzeigen </a>
<a href="/enrich" class="font-sans text-sm text-ink-2 hover:text-ink hover:underline"
>{m.dashboard_needs_metadata_show_all()}</a
>
</div>
</div>
{/if}

View File

@@ -1,8 +1,11 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
type Document = {
id: string;
title: string;
documentDate?: string;
updatedAt?: string;
sender?: { id: string; firstName: string; lastName: string };
};
@@ -13,33 +16,34 @@ interface Props {
let { recentDocs }: Props = $props();
function formatDate(dateStr: string): string {
return new Intl.DateTimeFormat('de-DE', {
// updatedAt is a full ISO datetime — no T12:00:00 noon-anchor needed here
return new Intl.DateTimeFormat(getLocale(), {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(new Date(dateStr + 'T12:00:00'));
}).format(new Date(dateStr));
}
</script>
{#if recentDocs.length > 0}
<div data-testid="dashboard-recent-docs" class="rounded-sm border border-line bg-surface p-6">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
Zuletzt hinzugefügt
{m.dashboard_recent_heading()}
</h2>
{#each recentDocs as doc (doc.id)}
<div class="flex items-center justify-between border-b border-line py-2 last:border-0">
<a
href="/documents/{doc.id}"
class="font-serif text-sm text-ink hover:text-ink-2 hover:underline"
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
>
{doc.title}
</a>
{#if doc.documentDate}
{#if doc.updatedAt}
<span
data-testid="doc-date-{doc.id}"
class="ml-2 shrink-0 font-sans text-xs text-gray-400"
>
{formatDate(doc.documentDate)}
{formatDate(doc.updatedAt)}
</span>
{/if}
</div>

View File

@@ -9,12 +9,12 @@ afterEach(cleanup);
type Document = {
id: string;
title: string;
documentDate?: string;
updatedAt?: string;
sender?: { id: string; firstName: string; lastName: string };
};
function makeDoc(id: string, title: string, date?: string): Document {
return { id, title, documentDate: date };
function makeDoc(id: string, title: string, updatedAt?: string): Document {
return { id, title, updatedAt };
}
describe('DashboardRecentDocuments', () => {

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as m from '$lib/paraglide/messages.js';
interface LastVisited {
id: string;
@@ -28,9 +29,9 @@ onMount(() => {
data-testid="resume-strip"
class="flex items-center gap-2 rounded-sm border border-line bg-surface px-4 py-3 font-sans text-sm"
>
<span class="text-ink-2">Zuletzt geöffnet:</span>
<span class="text-ink-2">{m.dashboard_resume_label()}</span>
<a href="/documents/{lastVisited.id}" class="font-medium text-ink hover:underline">
{lastVisited.title || 'Zuletzt geöffnet'}
{lastVisited.title || m.dashboard_resume_fallback()}
</a>
</div>
{/if}

View File

@@ -40,4 +40,11 @@ describe('DashboardResumeStrip', () => {
const link = page.getByRole('link');
await expect.element(link).toHaveAttribute('href', '/documents/doc-456');
});
it('renders nothing when localStorage contains malformed JSON', async () => {
localStorage.setItem('familienarchiv.lastVisited', '{not valid json');
render(DashboardResumeStrip, {});
const strip = page.getByTestId('resume-strip');
await expect.element(strip).not.toBeInTheDocument();
});
});

View File

@@ -136,9 +136,7 @@ let { doc }: { doc: Doc } = $props();
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
</div>
<div>
<p
class="font-serif text-ink decoration-brand-mint underline-offset-2 group-hover:underline"
>
<p class="font-serif text-ink group-hover:underline">
{doc.sender.firstName}
{doc.sender.lastName}
</p>

View File

@@ -628,6 +628,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/recent-activity": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getRecentActivity"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/incomplete": {
parameters: {
query?: never;
@@ -2328,6 +2344,28 @@ export interface operations {
};
};
};
getRecentActivity: {
parameters: {
query?: {
size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Document"][];
};
};
};
};
getIncomplete: {
parameters: {
query?: {

View File

@@ -58,13 +58,9 @@ export async function load({ url, fetch }) {
if (isDashboard) {
const [mentionsResult, incompleteResult, recentResult] = await Promise.allSettled([
api.GET('/api/notifications', {
params: { query: { type: 'MENTION', read: false, size: 5 } }
}),
api.GET('/api/notifications', { params: { query: { size: 5 } } }),
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
api.GET('/api/documents/search', {
params: { query: { status: 'REVIEWED' } }
})
api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } })
]);
if (mentionsResult.status === 'fulfilled' && mentionsResult.value.response.ok) {
@@ -74,7 +70,7 @@ export async function load({ url, fetch }) {
incompleteDocs = incompleteResult.value.data ?? [];
}
if (recentResult.status === 'fulfilled' && recentResult.value.response.ok) {
recentDocs = (recentResult.value.data ?? []).slice(0, 5);
recentDocs = recentResult.value.data ?? [];
}
}

View File

@@ -100,7 +100,7 @@ $effect(() => {
</div>
{/if}
<div class="mt-6 grid gap-4 lg:grid-cols-2">
<div class="mt-6 grid gap-4 {(data.mentions?.length ?? 0) > 0 ? 'lg:grid-cols-2' : ''}">
<DashboardMentions mentions={data.mentions ?? []} />
<DashboardNeedsMetadata incompleteDocs={data.incompleteDocs ?? []} />
</div>

View File

@@ -56,9 +56,7 @@ let {
<!-- Main Info -->
<div class="flex-1">
<div class="mb-2 flex items-baseline justify-between">
<h3
class="font-serif text-xl font-medium text-ink decoration-brand-mint decoration-2 underline-offset-4 group-hover:underline"
>
<h3 class="font-serif text-xl font-medium text-ink group-hover:underline">
{doc.title || doc.originalFilename}
</h3>
</div>

View File

@@ -74,9 +74,7 @@ const count = $derived(documents.length);
<li class="group transition-colors duration-200 hover:bg-muted">
<a href="/enrich/{doc.id}" class="flex items-center justify-between p-6">
<div class="min-w-0 flex-1">
<p
class="font-serif text-lg font-medium text-ink decoration-accent decoration-2 underline-offset-4 group-hover:underline"
>
<p class="font-serif text-lg font-medium text-ink group-hover:underline">
{doc.title}
</p>
</div>

View File

@@ -31,7 +31,7 @@ import { m } from '$lib/paraglide/messages.js';
<a
href="/enrich"
class="font-sans text-xs text-ink-2 underline-offset-4 transition-colors hover:text-ink hover:underline"
class="font-sans text-xs text-ink-2 transition-colors hover:text-ink hover:underline"
>
{m.enrich_back_to_list()}
</a>

View File

@@ -213,4 +213,10 @@
background-color: var(--c-surface);
color: var(--c-ink);
}
a:hover {
text-decoration-color: var(--c-accent);
text-decoration-thickness: 2px;
text-underline-offset: 4px;
}
}

View File

@@ -19,40 +19,115 @@ function makeUrl(params: Record<string, string | string[]> = {}) {
return url;
}
// ─── happy path ───────────────────────────────────────────────────────────────
// ─── dashboard mode (no search filters) ──────────────────────────────────────
describe('home page load — happy path', () => {
it('returns documents and persons on success', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi
.fn()
.mockResolvedValueOnce({
response: { ok: true, status: 200 },
data: [{ id: 'd1', title: 'Brief' }]
})
.mockResolvedValueOnce({
response: { ok: true, status: 200 },
data: [{ id: 'p1', firstName: 'Hans', lastName: 'Müller' }]
})
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 3 } })
} as ReturnType<typeof createApiClient>);
describe('home page load — dashboard mode', () => {
it('sets isDashboard true and fetches all three widget APIs', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
.mockResolvedValueOnce({
response: { ok: true },
data: { content: [{ id: 'n1' }] }
}) // notifications
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1' }] }) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd2' }] }); // recent
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(result.documents).toHaveLength(1);
expect(result.incompleteCount).toBe(3);
expect(result.error).toBeNull();
expect(result.isDashboard).toBe(true);
expect(result.mentions).toHaveLength(1);
expect(result.incompleteDocs).toHaveLength(1);
expect(result.recentDocs).toHaveLength(1);
expect(result.documents).toEqual([]);
});
it('passes search params from the URL to the API', async () => {
it('defaults mentions to [] when notifications API rejects', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
.mockRejectedValueOnce(new Error('network')) // notifications
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(result.mentions).toEqual([]);
});
it('defaults incompleteDocs to [] when incomplete API rejects', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
.mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // notifications
.mockRejectedValueOnce(new Error('network')) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(result.incompleteDocs).toEqual([]);
});
it('defaults recentDocs to [] when recent-activity API rejects', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
.mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // notifications
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
.mockRejectedValueOnce(new Error('network')); // recent
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(result.recentDocs).toEqual([]);
});
});
// ─── search mode (any filter active) ─────────────────────────────────────────
describe('home page load — search mode', () => {
it('sets isDashboard false and skips widget APIs when q is set', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [{ id: 'd1' }] }) // search docs
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // persons
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({
url: makeUrl({ q: 'Urlaub' }),
fetch: vi.fn() as unknown as typeof fetch
});
expect(result.isDashboard).toBe(false);
expect(result.documents).toHaveLength(1);
expect(result.mentions).toEqual([]);
expect(result.incompleteDocs).toEqual([]);
expect(result.recentDocs).toEqual([]);
// Only two API calls — no widget calls
expect(mockGet).toHaveBeenCalledTimes(2);
});
it('passes search params from the URL to the documents API', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } });
vi.mocked(createApiClient).mockReturnValue({
GET: mockGet
} as ReturnType<typeof createApiClient>);
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] });
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await load({
url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }),
@@ -63,46 +138,14 @@ describe('home page load — happy path', () => {
expect(firstCall[1].params.query.q).toBe('Urlaub');
expect(firstCall[1].params.query.from).toBe('2020-01-01');
});
it('returns incompleteCount 0 when the incomplete-count API fails', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
.mockResolvedValueOnce({ response: { ok: false }, data: null })
} as ReturnType<typeof createApiClient>);
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(result.incompleteCount).toBe(0);
});
});
// ─── 401 redirect ─────────────────────────────────────────────────────────────
describe('home page load — auth redirect', () => {
it('redirects to /login when documents API returns 401', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi
.fn()
.mockResolvedValueOnce({ response: { ok: false, status: 401 }, data: null })
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } })
} as ReturnType<typeof createApiClient>);
await expect(
load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch })
).rejects.toMatchObject({ location: '/login' });
});
it('redirects to /login when persons API returns 401', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
.mockResolvedValueOnce({ response: { ok: false, status: 401 }, data: null })
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } })
GET: vi.fn().mockResolvedValueOnce({ response: { ok: false, status: 401 }, data: null })
} as ReturnType<typeof createApiClient>);
await expect(
@@ -123,6 +166,5 @@ describe('home page load — network error fallback', () => {
expect(result.error).toBe('Daten konnten nicht geladen werden.');
expect(result.documents).toEqual([]);
expect(result.incompleteCount).toBe(0);
});
});

View File

@@ -119,9 +119,7 @@ function handleSearch() {
<!-- Info -->
<div class="min-w-0 flex-1">
<p
class="truncate font-serif text-base font-medium text-ink decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
>
<p class="truncate font-serif text-base font-medium text-ink group-hover:underline">
{person.firstName}
{person.lastName}
</p>

View File

@@ -86,7 +86,7 @@ const visibleDocuments = $derived(
</div>
<div class="min-w-0">
<div
class="truncate font-serif text-base font-medium text-ink decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
class="truncate font-serif text-base font-medium text-ink group-hover:underline"
>
{doc.title || doc.originalFilename}
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB