feat(frontend): add DocumentRow component with two-column layout, highlights, progress, contributors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
173
frontend/src/lib/components/DocumentRow.svelte
Normal file
173
frontend/src/lib/components/DocumentRow.svelte
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { applyOffsets } from '$lib/search';
|
||||||
|
import { formatDate } from '$lib/utils/date';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import ProgressRing from './ProgressRing.svelte';
|
||||||
|
import ContributorStack from './ContributorStack.svelte';
|
||||||
|
|
||||||
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
|
|
||||||
|
let { item }: { item: DocumentSearchItem } = $props();
|
||||||
|
|
||||||
|
const doc = $derived(item.document);
|
||||||
|
const titleText = $derived(doc.title || doc.originalFilename);
|
||||||
|
const titleOffsets = $derived(item.matchData?.titleOffsets ?? []);
|
||||||
|
const titleSegments = $derived(applyOffsets(titleText, titleOffsets));
|
||||||
|
const snippet = $derived(item.matchData?.transcriptionSnippet ?? null);
|
||||||
|
const snippetSegments = $derived(
|
||||||
|
snippet ? applyOffsets(snippet, item.matchData?.snippetOffsets ?? []) : null
|
||||||
|
);
|
||||||
|
const senderMatched = $derived(item.matchData?.senderMatched ?? false);
|
||||||
|
const matchedReceiverIds = $derived(new Set(item.matchData?.matchedReceiverIds ?? []));
|
||||||
|
const matchedTagIds = $derived(new Set(item.matchData?.matchedTagIds ?? []));
|
||||||
|
const hasMore = $derived(item.contributors.length >= 4);
|
||||||
|
|
||||||
|
function tagClass(matched: boolean): string {
|
||||||
|
return matched
|
||||||
|
? 'inline-flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-bold tracking-widest uppercase bg-primary text-primary-fg transition-colors'
|
||||||
|
: 'inline-flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-bold tracking-widest uppercase bg-muted text-ink hover:bg-primary hover:text-primary-fg transition-colors';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li class="group transition-colors duration-200 hover:bg-muted/50">
|
||||||
|
<a href="/documents/{doc.id}" class="block px-4 py-4 sm:py-5">
|
||||||
|
<div class="flex gap-0 sm:gap-5">
|
||||||
|
<!-- Left column -->
|
||||||
|
<div class="flex-1 sm:border-r sm:border-line sm:pr-5">
|
||||||
|
<!-- Title -->
|
||||||
|
<h3 class="mb-1 font-serif text-xl font-medium text-ink group-hover:underline">
|
||||||
|
{#each titleSegments as seg (seg.text + seg.highlight)}
|
||||||
|
{#if seg.highlight}
|
||||||
|
<mark
|
||||||
|
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||||
|
>{seg.text}</mark
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
{seg.text}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Snippet -->
|
||||||
|
{#if snippetSegments}
|
||||||
|
<p class="mb-2 line-clamp-2 font-sans text-sm text-ink-2 italic">
|
||||||
|
{#each snippetSegments as seg (seg.text + seg.highlight)}
|
||||||
|
{#if seg.highlight}
|
||||||
|
<mark
|
||||||
|
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||||
|
>{seg.text}</mark
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
{seg.text}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Sender / receivers — desktop only -->
|
||||||
|
<div class="mt-2 mb-2 hidden gap-4 font-sans text-xs text-ink-2 sm:grid sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_from()}</span>
|
||||||
|
<span class="ml-1">
|
||||||
|
{#if doc.sender}
|
||||||
|
{#if senderMatched}
|
||||||
|
<mark
|
||||||
|
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||||
|
>{doc.sender.displayName}</mark
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
{doc.sender.displayName}
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_to()}</span>
|
||||||
|
<span class="ml-1">
|
||||||
|
{#if doc.receivers && doc.receivers.length > 0}
|
||||||
|
{#each doc.receivers as receiver, i (receiver.id)}
|
||||||
|
{#if i > 0}<span>, </span>{/if}
|
||||||
|
{#if matchedReceiverIds.has(receiver.id)}
|
||||||
|
<mark
|
||||||
|
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||||
|
>{receiver.displayName}</mark
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
{receiver.displayName}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
{#if doc.tags && doc.tags.length > 0}
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{#each doc.tags as tag (tag.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={tagClass(matchedTagIds.has(tag.id))}
|
||||||
|
onclick={() => goto('/documents?tag=' + encodeURIComponent(tag.name))}
|
||||||
|
>
|
||||||
|
{#if tag.color}
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full" style="background-color: {tag.color};"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Mobile-only metadata -->
|
||||||
|
<div class="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 font-sans text-xs text-ink-2 sm:hidden">
|
||||||
|
<div>
|
||||||
|
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ProgressRing percentage={item.completionPercentage} />
|
||||||
|
<ContributorStack contributors={item.contributors} hasMore={hasMore} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column — desktop only -->
|
||||||
|
<div class="hidden flex-col gap-2 pl-4 font-sans text-xs text-ink-2 sm:flex sm:w-44 lg:w-56">
|
||||||
|
<div>
|
||||||
|
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_from()}</span>
|
||||||
|
<span class="ml-1">
|
||||||
|
{#if doc.sender}
|
||||||
|
{doc.sender.displayName}
|
||||||
|
{:else}
|
||||||
|
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_to()}</span>
|
||||||
|
<span class="ml-1">
|
||||||
|
{#if doc.receivers && doc.receivers.length > 0}
|
||||||
|
{doc.receivers.map((r) => r.displayName).join(', ')}
|
||||||
|
{:else}
|
||||||
|
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ProgressRing percentage={item.completionPercentage} />
|
||||||
|
<ContributorStack contributors={item.contributors} hasMore={hasMore} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
@@ -1,14 +1,25 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
|
type User = components['schemas']['AppUser'];
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
|
||||||
|
|
||||||
const baseData = {
|
const baseData = {
|
||||||
user: { firstName: 'Max' },
|
user: {
|
||||||
|
id: 'u1',
|
||||||
|
email: 'max@example.com',
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: '',
|
||||||
|
groups: [],
|
||||||
|
enabled: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00Z'
|
||||||
|
} as User,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
resumeDoc: null,
|
resumeDoc: null,
|
||||||
@@ -48,8 +59,7 @@ describe('Home page – dashboard layout', () => {
|
|||||||
title: 'Geburtsurkunde',
|
title: 'Geburtsurkunde',
|
||||||
caption: 'Max · 1920',
|
caption: 'Max · 1920',
|
||||||
excerpt: 'Hiermit…',
|
excerpt: 'Hiermit…',
|
||||||
page: 1,
|
totalBlocks: 3,
|
||||||
pages: 3,
|
|
||||||
pct: 33,
|
pct: 33,
|
||||||
collaborators: []
|
collaborators: []
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user