refactor: move document domain core to lib/document/
Moves ~25 components, utils (search, filename, groupDocuments, documentStatusLabel, validateFile), bulkSelection store, and TranscriptionSection sub-component. Fixes broken relative imports. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
229
frontend/src/lib/document/DocumentRow.svelte
Normal file
229
frontend/src/lib/document/DocumentRow.svelte
Normal file
@@ -0,0 +1,229 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { applyOffsets } from '$lib/document/search';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
||||
import ProgressRing from '$lib/components/ProgressRing.svelte';
|
||||
import ContributorStack from '$lib/components/ContributorStack.svelte';
|
||||
import DocumentThumbnail from './DocumentThumbnail.svelte';
|
||||
|
||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||
|
||||
let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $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 summary = $derived(doc.summary?.trim() ? doc.summary : null);
|
||||
const summarySegments = $derived(
|
||||
summary ? applyOffsets(summary, item.matchData?.summaryOffsets ?? []) : null
|
||||
);
|
||||
const archiveChips = $derived(
|
||||
[doc.archiveBox, doc.archiveFolder, doc.location].filter(
|
||||
(c): c is string => !!c && c.trim().length > 0
|
||||
)
|
||||
);
|
||||
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
|
||||
? 'pointer-events-auto inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs font-bold tracking-widest uppercase bg-primary text-primary-fg transition-colors'
|
||||
: 'pointer-events-auto inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs font-bold tracking-widest uppercase bg-muted text-ink hover:bg-primary hover:text-primary-fg transition-colors';
|
||||
}
|
||||
|
||||
function safeTagColor(color: string | null | undefined): string {
|
||||
return color && /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#cdcbbf';
|
||||
}
|
||||
</script>
|
||||
|
||||
<li class="group relative transition-colors duration-200 hover:bg-muted/50">
|
||||
<!--
|
||||
Stretched-link pattern: the row-wide anchor sits as an overlay so it
|
||||
isn't a parent of interior interactive controls (tag buttons). Nesting
|
||||
<button> inside <a> is invalid HTML and was causing tag clicks to also
|
||||
fire the row navigation in real browsers.
|
||||
-->
|
||||
<a href="/documents/{doc.id}" aria-label={titleText} class="absolute inset-0 z-0 block"></a>
|
||||
<div class="pointer-events-none relative z-10 px-4 py-4 sm:py-5">
|
||||
<div class="flex gap-3 sm:gap-5">
|
||||
<!-- Bulk-selection checkbox -->
|
||||
{#if canWrite}
|
||||
<label
|
||||
class="pointer-events-auto flex min-h-[44px] min-w-[44px] flex-shrink-0 cursor-pointer items-start pt-1"
|
||||
data-testid="bulk-select-checkbox"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 cursor-pointer accent-brand-navy"
|
||||
checked={bulkSelectionStore.has(doc.id)}
|
||||
onchange={() => bulkSelectionStore.toggle(doc.id)}
|
||||
aria-label={m.bulk_edit_select_document({ title: titleText })}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
<!-- Thumbnail tile -->
|
||||
<DocumentThumbnail doc={doc} size="lg" />
|
||||
|
||||
<!-- 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, i (i)}
|
||||
{#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
|
||||
data-testid="search-snippet"
|
||||
class="mb-2 line-clamp-2 font-sans text-sm text-ink-2 italic"
|
||||
>
|
||||
{#each snippetSegments as seg, i (i)}
|
||||
{#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}
|
||||
|
||||
<!-- Summary excerpt — only when populated -->
|
||||
{#if summarySegments}
|
||||
<p
|
||||
data-testid="doc-summary"
|
||||
class="mt-1 mb-2 line-clamp-2 font-serif text-sm text-ink-2 italic"
|
||||
>
|
||||
{#each summarySegments as seg, i (i)}
|
||||
{#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}
|
||||
|
||||
<!-- Archive metadata chips — desktop only -->
|
||||
{#if archiveChips.length > 0}
|
||||
<div class="mt-2 hidden flex-wrap items-center gap-1.5 sm:flex">
|
||||
{#each archiveChips as chip, i (i)}
|
||||
<span
|
||||
class="rounded border border-line px-1.5 py-0.5 font-sans text-[10px] tracking-widest text-ink-3 uppercase"
|
||||
>{chip}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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: {safeTagColor(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-start gap-2">
|
||||
<ProgressRing percentage={item.completionPercentage} />
|
||||
<div class="flex h-9 items-center">
|
||||
<ContributorStack contributors={item.contributors} hasMore={hasMore} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column — desktop only -->
|
||||
<div class="hidden flex-col gap-2 pl-4 font-sans text-sm 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}
|
||||
{#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 class="flex items-start gap-2">
|
||||
<ProgressRing percentage={item.completionPercentage} />
|
||||
<div class="flex h-9 items-center">
|
||||
<ContributorStack contributors={item.contributors} hasMore={hasMore} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
Reference in New Issue
Block a user