feat(topbar): responsive DocumentTopBar — issue #173 #174
@@ -223,6 +223,12 @@
|
||||
"admin_label_initial_password": "Passwort",
|
||||
"doc_file_error_preview": "Vorschau konnte nicht geladen werden.",
|
||||
"doc_download_title": "Herunterladen",
|
||||
"topbar_back_label": "Zurück zur Dokumentenliste",
|
||||
"topbar_more_actions": "Weitere Aktionen",
|
||||
"topbar_overflow_more": "+{count} weitere",
|
||||
"topbar_overflow_suffix": "weitere",
|
||||
"topbar_overflow_heading": "Weitere Empfänger",
|
||||
"topbar_overflow_show": "{count} weitere Empfänger anzeigen",
|
||||
"doc_tag_filter_title": "Nach {name} filtern",
|
||||
"doc_conversation_title": "Konversation anzeigen",
|
||||
"doc_preview_iframe_title": "Dokumentvorschau",
|
||||
@@ -321,6 +327,7 @@
|
||||
"doc_panel_tab_history": "Verlauf",
|
||||
"doc_panel_annotate": "Annotieren",
|
||||
"doc_panel_annotate_stop": "Fertig",
|
||||
"doc_panel_annotate_hint": "Klicken und ziehen Sie, um einen Bereich zu markieren",
|
||||
"doc_panel_annotation_thread_title": "Annotation",
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
||||
"pdf_annotations_show": "Annotierungen anzeigen",
|
||||
|
||||
@@ -223,6 +223,12 @@
|
||||
"admin_label_initial_password": "Password",
|
||||
"doc_file_error_preview": "Could not load preview.",
|
||||
"doc_download_title": "Download",
|
||||
"topbar_back_label": "Back to document list",
|
||||
"topbar_more_actions": "More actions",
|
||||
"topbar_overflow_more": "+{count} more",
|
||||
"topbar_overflow_suffix": "more",
|
||||
"topbar_overflow_heading": "More receivers",
|
||||
"topbar_overflow_show": "Show {count} more receivers",
|
||||
"doc_tag_filter_title": "Filter by {name}",
|
||||
"doc_conversation_title": "Show conversation",
|
||||
"doc_preview_iframe_title": "Document Preview",
|
||||
@@ -321,6 +327,7 @@
|
||||
"doc_panel_tab_history": "History",
|
||||
"doc_panel_annotate": "Annotate",
|
||||
"doc_panel_annotate_stop": "Done",
|
||||
"doc_panel_annotate_hint": "Click and drag to mark an area",
|
||||
"doc_panel_annotation_thread_title": "Annotation",
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
||||
"pdf_annotations_show": "Show annotations",
|
||||
|
||||
@@ -223,6 +223,12 @@
|
||||
"admin_label_initial_password": "Contraseña",
|
||||
"doc_file_error_preview": "No se pudo cargar la vista previa.",
|
||||
"doc_download_title": "Descargar",
|
||||
"topbar_back_label": "Volver a la lista de documentos",
|
||||
"topbar_more_actions": "Más acciones",
|
||||
"topbar_overflow_more": "+{count} más",
|
||||
"topbar_overflow_suffix": "más",
|
||||
"topbar_overflow_heading": "Más destinatarios",
|
||||
"topbar_overflow_show": "Mostrar {count} destinatarios más",
|
||||
"doc_tag_filter_title": "Filtrar por {name}",
|
||||
"doc_conversation_title": "Ver conversación",
|
||||
"doc_preview_iframe_title": "Vista previa del documento",
|
||||
@@ -321,6 +327,7 @@
|
||||
"doc_panel_tab_history": "Historial",
|
||||
"doc_panel_annotate": "Anotar",
|
||||
"doc_panel_annotate_stop": "Listo",
|
||||
"doc_panel_annotate_hint": "Haga clic y arrastre para marcar un área",
|
||||
"doc_panel_annotation_thread_title": "Anotación",
|
||||
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
||||
"pdf_annotations_show": "Mostrar anotaciones",
|
||||
|
||||
64
frontend/src/lib/actions/clickOutside.svelte.spec.ts
Normal file
64
frontend/src/lib/actions/clickOutside.svelte.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
const { clickOutside } = await import('./clickOutside');
|
||||
|
||||
describe('clickOutside action', () => {
|
||||
const nodes: HTMLElement[] = [];
|
||||
|
||||
function makeNode(): HTMLElement {
|
||||
const node = document.createElement('div');
|
||||
document.body.appendChild(node);
|
||||
nodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
nodes.forEach((n) => n.remove());
|
||||
nodes.length = 0;
|
||||
});
|
||||
|
||||
it('registers a capture-phase click listener on mount', () => {
|
||||
const node = makeNode();
|
||||
const original = document.addEventListener.bind(document);
|
||||
let registered = false;
|
||||
document.addEventListener = (type: string, _fn: unknown, opts: unknown) => {
|
||||
if (type === 'click' && opts === true) registered = true;
|
||||
original(type as string, _fn as EventListener, opts as boolean);
|
||||
};
|
||||
clickOutside(node);
|
||||
expect(registered).toBe(true);
|
||||
document.addEventListener = original;
|
||||
});
|
||||
|
||||
it('dispatches clickoutside when clicking outside the node', () => {
|
||||
const node = makeNode();
|
||||
const outside = makeNode();
|
||||
let fired = false;
|
||||
node.addEventListener('clickoutside', () => (fired = true));
|
||||
clickOutside(node);
|
||||
outside.click();
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
|
||||
it('does not dispatch clickoutside when clicking inside the node', () => {
|
||||
const node = makeNode();
|
||||
const child = document.createElement('span');
|
||||
node.appendChild(child);
|
||||
let fired = false;
|
||||
node.addEventListener('clickoutside', () => (fired = true));
|
||||
clickOutside(node);
|
||||
child.click();
|
||||
expect(fired).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the listener on destroy', () => {
|
||||
const node = makeNode();
|
||||
const outside = makeNode();
|
||||
let count = 0;
|
||||
node.addEventListener('clickoutside', () => count++);
|
||||
const { destroy } = clickOutside(node);
|
||||
destroy();
|
||||
outside.click();
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
15
frontend/src/lib/actions/clickOutside.ts
Normal file
15
frontend/src/lib/actions/clickOutside.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function clickOutside(node: HTMLElement): { destroy: () => void } {
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
node.dispatchEvent(new CustomEvent('clickoutside'));
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleClick, true);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
22
frontend/src/lib/components/AnnotateHintStrip.svelte
Normal file
22
frontend/src/lib/components/AnnotateHintStrip.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Props = {
|
||||
annotateMode: boolean;
|
||||
};
|
||||
|
||||
let { annotateMode }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if annotateMode}
|
||||
<div
|
||||
data-testid="annotate-hint-strip"
|
||||
class="hidden h-[29px] items-center gap-2 border-t border-dashed px-3.5 md:flex"
|
||||
style="background: rgba(1,40,81,0.05); border-color: rgba(1,40,81,0.20)"
|
||||
>
|
||||
<span class="text-[16px] font-bold tracking-wide text-primary uppercase"
|
||||
>{m.doc_panel_annotate()}</span
|
||||
>
|
||||
<span class="text-[16px] text-ink-2">{m.doc_panel_annotate_hint()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
27
frontend/src/lib/components/AnnotateHintStrip.svelte.spec.ts
Normal file
27
frontend/src/lib/components/AnnotateHintStrip.svelte.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('AnnotateHintStrip', () => {
|
||||
it('is absent from the DOM when annotateMode is false', async () => {
|
||||
render(AnnotateHintStrip, { annotateMode: false });
|
||||
const strip = page.getByTestId('annotate-hint-strip');
|
||||
await expect.element(strip).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is present in the DOM when annotateMode is true', async () => {
|
||||
render(AnnotateHintStrip, { annotateMode: true });
|
||||
const strip = page.getByTestId('annotate-hint-strip');
|
||||
await expect.element(strip).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has hidden md:flex class to hide below 768px', async () => {
|
||||
render(AnnotateHintStrip, { annotateMode: true });
|
||||
const strip = page.getByTestId('annotate-hint-strip');
|
||||
await expect.element(strip).toHaveClass('hidden');
|
||||
await expect.element(strip).toHaveClass('md:flex');
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/components/DocumentStatusChip.svelte
Normal file
20
frontend/src/lib/components/DocumentStatusChip.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { statusDotClass, statusLabel } from '$lib/utils/personFormat';
|
||||
|
||||
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
|
||||
|
||||
type Props = {
|
||||
status: DocumentStatus;
|
||||
};
|
||||
|
||||
let { status }: Props = $props();
|
||||
|
||||
const dotClass = $derived(statusDotClass(status));
|
||||
const label = $derived(statusLabel(status));
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="hidden shrink-0 md:block {dotClass} h-4 w-4 rounded-full"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
></span>
|
||||
@@ -1,5 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/personFormat';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import PersonChipRow from './PersonChipRow.svelte';
|
||||
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
|
||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
|
||||
@@ -25,128 +30,207 @@ type Props = {
|
||||
let { doc, canWrite, canAnnotate, fileUrl, annotateMode = $bindable() }: Props = $props();
|
||||
|
||||
const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('application/pdf'));
|
||||
const receivers = $derived(doc.receivers ?? []);
|
||||
const extraCount = $derived(Math.max(0, receivers.length - 2));
|
||||
const overflowPersons = $derived(receivers.slice(2));
|
||||
|
||||
const receiverDisplay = $derived.by(() => {
|
||||
const receivers = doc.receivers ?? [];
|
||||
if (receivers.length === 0) return null;
|
||||
const shown = receivers.slice(0, 2);
|
||||
const extra = receivers.length - shown.length;
|
||||
const names = shown.map((r) => `${r.firstName} ${r.lastName}`).join(', ');
|
||||
return extra > 0 ? `${names} +${extra}` : names;
|
||||
});
|
||||
const shortDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
|
||||
const longDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'long') : null);
|
||||
|
||||
const compactMeta = $derived.by(() => {
|
||||
const parts: string[] = [];
|
||||
if (doc.documentDate) {
|
||||
parts.push(
|
||||
new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric'
|
||||
}).format(new Date(doc.documentDate + 'T12:00:00'))
|
||||
);
|
||||
}
|
||||
if (doc.sender) {
|
||||
const senderName = `${doc.sender.firstName} ${doc.sender.lastName}`;
|
||||
const receiver = receiverDisplay;
|
||||
parts.push(receiver ? `${senderName} → ${receiver}` : senderName);
|
||||
} else if (receiverDisplay) {
|
||||
parts.push(`→ ${receiverDisplay}`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
});
|
||||
let mobileMenuOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="z-20 flex shrink-0 items-center justify-between border-b border-line bg-surface px-3 py-3 shadow-sm sm:px-6"
|
||||
data-topbar
|
||||
>
|
||||
<!-- Left: back + title -->
|
||||
<div class="flex min-w-0 items-center gap-4 overflow-hidden">
|
||||
{#snippet annotateBtn(mobile: boolean)}
|
||||
<button
|
||||
onclick={() => {
|
||||
annotateMode = true;
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
aria-label={m.doc_panel_annotate()}
|
||||
aria-pressed={false}
|
||||
class={mobile
|
||||
? 'flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
|
||||
: 'hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex'}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0"
|
||||
/>
|
||||
{m.doc_panel_annotate()}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet annotateStopBtn(mobile: boolean)}
|
||||
<button
|
||||
onclick={() => {
|
||||
annotateMode = false;
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
aria-label={m.doc_panel_annotate_stop()}
|
||||
aria-pressed={true}
|
||||
class={mobile
|
||||
? 'flex w-full items-center gap-2 rounded bg-primary px-3 py-2 text-left text-[16px] text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'
|
||||
: 'flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 font-sans text-[16px] font-medium text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0 invert"
|
||||
/>
|
||||
{m.doc_panel_annotate_stop()}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet downloadLink(mobile: boolean)}
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={doc.originalFilename}
|
||||
onclick={() => {
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
class={mobile
|
||||
? 'flex items-center gap-2 rounded px-3 py-2 text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
|
||||
: 'hidden rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary md:block'}
|
||||
title={m.doc_download_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0"
|
||||
/>
|
||||
{#if mobile}{m.doc_download_title()}{/if}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<div data-topbar class="relative z-10 border-b border-line bg-surface shadow-sm">
|
||||
<!-- Main row -->
|
||||
<div class="flex h-[75px] shrink-0 items-center xs:h-[88px]">
|
||||
<!-- Accent bar -->
|
||||
<div class="h-full w-[3px] shrink-0 bg-primary"></div>
|
||||
|
||||
<!-- Back link — 44×44px touch target -->
|
||||
<a
|
||||
href="/"
|
||||
class="group flex shrink-0 items-center gap-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
aria-label={m.topbar_back_label()}
|
||||
class="group -ml-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-canvas transition-colors group-hover:bg-accent"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<span class="hidden sm:inline">{m.btn_back()}</span>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div class="min-w-0 border-l border-line pl-4">
|
||||
<!-- Divider -->
|
||||
<div class="mx-2 h-6 w-px shrink-0 bg-line"></div>
|
||||
|
||||
<!-- Title + meta -->
|
||||
<div class="min-w-0 flex-1 overflow-hidden">
|
||||
<h1
|
||||
class="truncate font-serif text-base leading-tight text-ink"
|
||||
class="truncate font-serif text-[18px] leading-tight text-ink lg:text-[20px]"
|
||||
title={doc.title ?? doc.originalFilename ?? ''}
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</h1>
|
||||
{#if compactMeta}
|
||||
<p class="truncate font-sans text-xs text-ink-2" title={compactMeta}>
|
||||
{compactMeta}
|
||||
{#if shortDate}
|
||||
<p class="font-sans text-[16px] text-ink-2">
|
||||
<span class="lg:hidden">{shortDate}</span>
|
||||
<span class="hidden lg:inline">{longDate}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chip row — desktop only, hidden on small screens to make room for buttons -->
|
||||
<div class="mx-3 hidden min-w-0 shrink-0 md:block">
|
||||
<PersonChipRow sender={doc.sender} receivers={receivers} abbreviated={true} extraCount={0} />
|
||||
</div>
|
||||
|
||||
<!-- Overflow pill button (desktop) + status dot -->
|
||||
{#if extraCount > 0}
|
||||
<OverflowPillButton extraCount={extraCount} persons={overflowPersons} />
|
||||
{/if}
|
||||
|
||||
<!-- Divider between metadata and actions -->
|
||||
<div class="mx-3 hidden h-6 w-px shrink-0 bg-line md:block"></div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex shrink-0 items-center gap-1.5 pr-3 font-sans">
|
||||
{#if canAnnotate && isPdf && !annotateMode}
|
||||
{@render annotateBtn(false)}
|
||||
{/if}
|
||||
|
||||
{#if canAnnotate && isPdf && annotateMode}
|
||||
{@render annotateStopBtn(false)}
|
||||
{/if}
|
||||
|
||||
{#if canWrite && !annotateMode}
|
||||
<a
|
||||
href="/documents/{doc.id}/edit"
|
||||
aria-label={m.btn_edit()}
|
||||
class="flex items-center gap-1.5 rounded border border-primary bg-transparent px-3 py-1.5 text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
<span class="hidden sm:inline">{m.btn_edit()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath && !annotateMode}
|
||||
{@render downloadLink(false)}
|
||||
{/if}
|
||||
|
||||
<!-- Kebab menu — mobile only, contains actions hidden below md -->
|
||||
{#if (canAnnotate && isPdf) || doc.filePath}
|
||||
<div
|
||||
role="group"
|
||||
class="relative md:hidden"
|
||||
use:clickOutside
|
||||
onclickoutside={() => (mobileMenuOpen = false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
aria-label={m.topbar_more_actions()}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
class="flex h-9 w-9 items-center justify-center rounded border border-line bg-muted transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/View-More-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if mobileMenuOpen}
|
||||
<div
|
||||
role="menu"
|
||||
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
|
||||
>
|
||||
{#if canAnnotate && isPdf && !annotateMode}
|
||||
{@render annotateBtn(true)}
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath}
|
||||
{@render downloadLink(true)}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="ml-4 flex shrink-0 items-center gap-2 font-sans">
|
||||
{#if canAnnotate && isPdf}
|
||||
<button
|
||||
onclick={() => (annotateMode = !annotateMode)}
|
||||
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
||||
class="flex items-center gap-1.5 rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'border border-primary text-ink hover:bg-primary hover:text-primary-fg'}"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 {annotateMode ? 'invert' : ''}"
|
||||
/>
|
||||
<span class="hidden sm:inline"
|
||||
>{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}</span
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if canWrite}
|
||||
<a
|
||||
href="/documents/{doc.id}/edit"
|
||||
aria-label={m.btn_edit()}
|
||||
class="flex items-center gap-2 rounded border border-primary bg-transparent px-3 py-1.5 text-xs font-medium text-ink transition hover:bg-primary hover:text-primary-fg"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="hidden sm:inline">{m.btn_edit()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath}
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={doc.originalFilename}
|
||||
class="rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent"
|
||||
title={m.doc_download_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Hint strip — only when annotateMode, only at ≥768px -->
|
||||
<AnnotateHintStrip annotateMode={annotateMode} />
|
||||
</div>
|
||||
|
||||
77
frontend/src/lib/components/OverflowPillButton.svelte
Normal file
77
frontend/src/lib/components/OverflowPillButton.svelte
Normal file
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
|
||||
type Props = {
|
||||
extraCount: number;
|
||||
persons: Person[];
|
||||
};
|
||||
|
||||
let { extraCount, persons }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let buttonEl: HTMLButtonElement | undefined = $state();
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
async function close() {
|
||||
open = false;
|
||||
await tick();
|
||||
buttonEl?.focus();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="group"
|
||||
class="relative hidden md:block"
|
||||
use:clickOutside
|
||||
onclickoutside={() => (open = false)}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<button
|
||||
bind:this={buttonEl}
|
||||
type="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-label={m.topbar_overflow_show({ count: extraCount })}
|
||||
onclick={toggle}
|
||||
onkeydown={handleKeydown}
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted px-2 py-0.5 text-[14px] font-bold text-ink-2 hover:bg-surface focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
+{extraCount}<span class="hidden lg:inline"> {m.topbar_overflow_suffix()}</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
role="list"
|
||||
class="absolute top-full left-0 z-50 mt-1 min-w-[160px] rounded-md border border-line bg-surface p-3 shadow-lg"
|
||||
>
|
||||
<p class="mb-2 text-[14px] font-bold tracking-wide text-ink-2 uppercase">
|
||||
{m.topbar_overflow_heading()}
|
||||
</p>
|
||||
{#each persons as person (person.id)}
|
||||
<div role="listitem">
|
||||
<a
|
||||
href="/persons/{person.id}"
|
||||
class="block py-0.5 text-[18px] text-ink hover:text-primary focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const persons = [
|
||||
{ id: 'p1', firstName: 'Anna', lastName: 'Müller' },
|
||||
{ id: 'p2', firstName: 'Hans', lastName: 'Schmidt' }
|
||||
];
|
||||
|
||||
describe('OverflowPillButton', () => {
|
||||
it('renders button with correct aria-haspopup and collapsed aria-expanded', async () => {
|
||||
render(OverflowPillButton, { extraCount: 2, persons });
|
||||
const btn = page.getByRole('button');
|
||||
await expect.element(btn).toHaveAttribute('aria-haspopup', 'true');
|
||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('shows tooltip on click and sets aria-expanded true', async () => {
|
||||
render(OverflowPillButton, { extraCount: 2, persons });
|
||||
const btn = page.getByRole('button');
|
||||
await userEvent.click(btn);
|
||||
const tooltip = page.getByRole('list');
|
||||
await expect.element(tooltip).toBeInTheDocument();
|
||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('closes tooltip on Escape and returns focus to button', async () => {
|
||||
render(OverflowPillButton, { extraCount: 2, persons });
|
||||
const btn = page.getByRole('button');
|
||||
await userEvent.click(btn);
|
||||
await expect.element(page.getByRole('list')).toBeInTheDocument();
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await expect.element(page.getByRole('list')).not.toBeInTheDocument();
|
||||
await expect.element(btn).toHaveFocus();
|
||||
});
|
||||
|
||||
it('renders person links inside tooltip', async () => {
|
||||
render(OverflowPillButton, { extraCount: 2, persons });
|
||||
await userEvent.click(page.getByRole('button'));
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links.nth(0)).toHaveAttribute('href', '/persons/p1');
|
||||
await expect.element(links.nth(1)).toHaveAttribute('href', '/persons/p2');
|
||||
});
|
||||
});
|
||||
14
frontend/src/lib/components/OverflowPillDisplay.svelte
Normal file
14
frontend/src/lib/components/OverflowPillDisplay.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
extraCount: number;
|
||||
};
|
||||
|
||||
let { extraCount }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted px-2 py-0.5 text-[14px] font-bold text-ink-2"
|
||||
>
|
||||
+{extraCount}
|
||||
</span>
|
||||
34
frontend/src/lib/components/PersonChip.svelte
Normal file
34
frontend/src/lib/components/PersonChip.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { abbreviateName, personAvatarColor } from '$lib/utils/personFormat';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
|
||||
type Props = {
|
||||
person: Person;
|
||||
abbreviated: boolean;
|
||||
};
|
||||
|
||||
let { person, abbreviated }: Props = $props();
|
||||
|
||||
const displayName = $derived(
|
||||
abbreviated ? abbreviateName(person) : `${person.firstName} ${person.lastName}`
|
||||
);
|
||||
const avatarColor = $derived(personAvatarColor(person.id));
|
||||
const initials = $derived(
|
||||
`${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase()
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/persons/{person.id}"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-muted px-2 py-0.5 hover:border-primary hover:bg-surface focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<span
|
||||
class="flex h-[25px] w-[25px] shrink-0 items-center justify-center rounded-full text-[13px] font-bold text-white"
|
||||
style="background-color: {avatarColor}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<span class="text-[14px] font-semibold text-ink">{displayName}</span>
|
||||
</a>
|
||||
42
frontend/src/lib/components/PersonChipRow.svelte
Normal file
42
frontend/src/lib/components/PersonChipRow.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import PersonChip from './PersonChip.svelte';
|
||||
import OverflowPillDisplay from './OverflowPillDisplay.svelte';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
|
||||
type Props = {
|
||||
sender: Person | null | undefined;
|
||||
receivers: Person[];
|
||||
abbreviated: boolean;
|
||||
extraCount: number;
|
||||
};
|
||||
|
||||
let { sender, receivers, abbreviated, extraCount }: Props = $props();
|
||||
|
||||
const visibleReceivers = $derived(receivers.slice(0, 2));
|
||||
</script>
|
||||
|
||||
<div class="hidden min-w-0 items-center gap-1.5 overflow-hidden xs:flex">
|
||||
{#if sender}
|
||||
<PersonChip person={sender} abbreviated={abbreviated} />
|
||||
{/if}
|
||||
|
||||
{#if sender && receivers.length > 0}
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6 shrink-0 opacity-40"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#each visibleReceivers as receiver, i (receiver.id)}
|
||||
<span class={i === 1 ? 'hidden md:contents' : ''}>
|
||||
<PersonChip person={receiver} abbreviated={abbreviated} />
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
{#if extraCount > 0}
|
||||
<OverflowPillDisplay extraCount={extraCount} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
@@ -56,20 +57,6 @@ function selectPerson(person: Person) {
|
||||
function removePerson(id: string | undefined) {
|
||||
selectedPersons = selectedPersons.filter((p) => p.id !== id);
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
@@ -78,7 +65,7 @@ function clickOutside(node: HTMLElement) {
|
||||
<input type="hidden" name="receiverIds" value={person.id} />
|
||||
{/each}
|
||||
|
||||
<div class="relative" use:clickOutside>
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
<div
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { untrack } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
@@ -118,23 +119,9 @@ function selectPerson(person: Person) {
|
||||
showDropdown = false;
|
||||
onchange?.(person.id!);
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative" use:clickOutside>
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
<label
|
||||
for={name}
|
||||
class={compact
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
interface Props {
|
||||
tags?: string[];
|
||||
@@ -66,23 +67,9 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
||||
}
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||
showSuggestions = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full" use:clickOutside>
|
||||
<div class="w-full" use:clickOutside onclickoutside={() => (showSuggestions = false)}>
|
||||
<!-- Tag Container -->
|
||||
<div
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||
|
||||
171
frontend/src/lib/utils/personFormat.spec.ts
Normal file
171
frontend/src/lib/utils/personFormat.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
abbreviateName,
|
||||
formatXsMeta,
|
||||
personAvatarColor,
|
||||
formatDate,
|
||||
statusDotClass,
|
||||
statusLabel
|
||||
} from './personFormat';
|
||||
|
||||
// ─── abbreviateName ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('abbreviateName', () => {
|
||||
it('abbreviates first name to initial + last name', () => {
|
||||
expect(abbreviateName({ firstName: 'Karl', lastName: 'Raddatz' })).toBe('K. Raddatz');
|
||||
});
|
||||
|
||||
it('returns single name as-is when no last name', () => {
|
||||
expect(abbreviateName({ firstName: 'Elfriede', lastName: '' })).toBe('Elfriede');
|
||||
});
|
||||
|
||||
it('preserves hyphenated last name', () => {
|
||||
expect(abbreviateName({ firstName: 'Karl', lastName: 'Müller-Schmidt' })).toBe(
|
||||
'K. Müller-Schmidt'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles leading/trailing whitespace in names', () => {
|
||||
expect(abbreviateName({ firstName: ' Karl ', lastName: ' Raddatz ' })).toBe('K. Raddatz');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatXsMeta ────────────────────────────────────────────────────────────
|
||||
|
||||
type Doc = {
|
||||
sender?: { firstName: string; lastName: string } | null;
|
||||
receivers?: { firstName: string; lastName: string }[];
|
||||
documentDate?: string | null;
|
||||
};
|
||||
|
||||
describe('formatXsMeta', () => {
|
||||
const sender = { firstName: 'Karl', lastName: 'Raddatz' };
|
||||
const receiver1 = { firstName: 'Elfriede', lastName: 'Raddatz' };
|
||||
const receiver2 = { firstName: 'Anna', lastName: 'Müller' };
|
||||
const receiver3 = { firstName: 'Hans', lastName: 'Schmidt' };
|
||||
|
||||
it('formats sender with no receivers and date', () => {
|
||||
const doc: Doc = { sender, receivers: [], documentDate: '1943-12-24' };
|
||||
expect(formatXsMeta(doc)).toBe('K.Raddatz · 24.12.1943');
|
||||
});
|
||||
|
||||
it('formats sender with one receiver and date', () => {
|
||||
const doc: Doc = { sender, receivers: [receiver1], documentDate: '1943-12-24' };
|
||||
expect(formatXsMeta(doc)).toBe('K.Raddatz → E.Raddatz · 24.12.1943');
|
||||
});
|
||||
|
||||
it('formats sender with three receivers showing +2', () => {
|
||||
const doc: Doc = {
|
||||
sender,
|
||||
receivers: [receiver1, receiver2, receiver3],
|
||||
documentDate: '1943-12-24'
|
||||
};
|
||||
expect(formatXsMeta(doc)).toBe('K.Raddatz → E.Raddatz +2 · 24.12.1943');
|
||||
});
|
||||
|
||||
it('formats without sender', () => {
|
||||
const doc: Doc = { sender: null, receivers: [receiver1], documentDate: '1943-12-24' };
|
||||
expect(formatXsMeta(doc)).toBe('E.Raddatz · 24.12.1943');
|
||||
});
|
||||
|
||||
it('formats without date', () => {
|
||||
const doc: Doc = { sender, receivers: [], documentDate: null };
|
||||
expect(formatXsMeta(doc)).toBe('K.Raddatz');
|
||||
});
|
||||
|
||||
it('formats with no sender and no date', () => {
|
||||
const doc: Doc = { sender: null, receivers: [receiver1], documentDate: null };
|
||||
expect(formatXsMeta(doc)).toBe('E.Raddatz');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── personAvatarColor ───────────────────────────────────────────────────────
|
||||
|
||||
const PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'];
|
||||
|
||||
describe('personAvatarColor', () => {
|
||||
it('returns a value from the palette', () => {
|
||||
expect(PALETTE).toContain(personAvatarColor('abc'));
|
||||
});
|
||||
|
||||
it('is deterministic — same id always returns same color', () => {
|
||||
const id = '550e8400-e29b-41d4-a716-446655440000';
|
||||
expect(personAvatarColor(id)).toBe(personAvatarColor(id));
|
||||
});
|
||||
|
||||
it('all 5 palette entries are reachable across 1000 random UUIDs', () => {
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
seen.add(personAvatarColor(crypto.randomUUID()));
|
||||
}
|
||||
expect(seen.size).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatDate ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats short date as dd.mm.yyyy', () => {
|
||||
expect(formatDate('1943-12-24', 'short')).toBe('24.12.1943');
|
||||
});
|
||||
|
||||
it('formats long date with German month name', () => {
|
||||
expect(formatDate('1943-12-24', 'long')).toBe('24. Dezember 1943');
|
||||
});
|
||||
|
||||
it('does not shift Dec 31 to Jan 1 (UTC off-by-one guard)', () => {
|
||||
expect(formatDate('1943-12-31', 'short')).toBe('31.12.1943');
|
||||
});
|
||||
|
||||
it('does not shift Jan 1 to Dec 31 (UTC off-by-one guard)', () => {
|
||||
expect(formatDate('1944-01-01', 'short')).toBe('01.01.1944');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── statusDotClass ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('statusDotClass', () => {
|
||||
it('PLACEHOLDER → bg-gray-400', () => {
|
||||
expect(statusDotClass('PLACEHOLDER')).toBe('bg-gray-400');
|
||||
});
|
||||
|
||||
it('UPLOADED → bg-emerald-500', () => {
|
||||
expect(statusDotClass('UPLOADED')).toBe('bg-emerald-500');
|
||||
});
|
||||
|
||||
it('TRANSCRIBED → bg-blue-400', () => {
|
||||
expect(statusDotClass('TRANSCRIBED')).toBe('bg-blue-400');
|
||||
});
|
||||
|
||||
it('REVIEWED → bg-amber-400', () => {
|
||||
expect(statusDotClass('REVIEWED')).toBe('bg-amber-400');
|
||||
});
|
||||
|
||||
it('ARCHIVED → bg-emerald-600', () => {
|
||||
expect(statusDotClass('ARCHIVED')).toBe('bg-emerald-600');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── statusLabel ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('statusLabel', () => {
|
||||
it('PLACEHOLDER → "Platzhalter"', () => {
|
||||
expect(statusLabel('PLACEHOLDER')).toBe('Platzhalter');
|
||||
});
|
||||
|
||||
it('UPLOADED → "Hochgeladen"', () => {
|
||||
expect(statusLabel('UPLOADED')).toBe('Hochgeladen');
|
||||
});
|
||||
|
||||
it('TRANSCRIBED → "Transkribiert"', () => {
|
||||
expect(statusLabel('TRANSCRIBED')).toBe('Transkribiert');
|
||||
});
|
||||
|
||||
it('REVIEWED → "Geprüft"', () => {
|
||||
expect(statusLabel('REVIEWED')).toBe('Geprüft');
|
||||
});
|
||||
|
||||
it('ARCHIVED → "Archiviert"', () => {
|
||||
expect(statusLabel('ARCHIVED')).toBe('Archiviert');
|
||||
});
|
||||
});
|
||||
102
frontend/src/lib/utils/personFormat.ts
Normal file
102
frontend/src/lib/utils/personFormat.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { formatDocumentStatus } from './documentStatusLabel';
|
||||
|
||||
type Person = { firstName: string; lastName: string };
|
||||
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
|
||||
type DocForMeta = {
|
||||
sender?: Person | null;
|
||||
receivers?: Person[];
|
||||
documentDate?: string | null;
|
||||
};
|
||||
|
||||
const AVATAR_PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'] as const;
|
||||
|
||||
function djb2(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash * 33) ^ str.charCodeAt(i);
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function abbreviateName(person: Person): string {
|
||||
const first = person.firstName.trim();
|
||||
const last = person.lastName.trim();
|
||||
if (!last) return first;
|
||||
return `${first.charAt(0)}. ${last}`;
|
||||
}
|
||||
|
||||
function abbreviateCompact(person: Person): string {
|
||||
const first = person.firstName.trim();
|
||||
const last = person.lastName.trim();
|
||||
if (!last) return first;
|
||||
return `${first.charAt(0)}.${last}`;
|
||||
}
|
||||
|
||||
export function formatXsMeta(doc: DocForMeta): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
const receivers = doc.receivers ?? [];
|
||||
if (doc.sender) {
|
||||
const senderAbbr = abbreviateCompact(doc.sender);
|
||||
if (receivers.length === 0) {
|
||||
parts.push(senderAbbr);
|
||||
} else {
|
||||
const extra = receivers.length - 1;
|
||||
const firstReceiver = abbreviateCompact(receivers[0]);
|
||||
parts.push(
|
||||
extra > 0
|
||||
? `${senderAbbr} → ${firstReceiver} +${extra}`
|
||||
: `${senderAbbr} → ${firstReceiver}`
|
||||
);
|
||||
}
|
||||
} else if (receivers.length > 0) {
|
||||
const extra = receivers.length - 1;
|
||||
const firstReceiver = abbreviateCompact(receivers[0]);
|
||||
parts.push(extra > 0 ? `${firstReceiver} +${extra}` : firstReceiver);
|
||||
}
|
||||
|
||||
if (doc.documentDate) {
|
||||
parts.push(formatDate(doc.documentDate, 'short'));
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function personAvatarColor(personId: string): string {
|
||||
return AVATAR_PALETTE[djb2(personId) % AVATAR_PALETTE.length];
|
||||
}
|
||||
|
||||
export function formatDate(isoDate: string, format: 'short' | 'long'): string {
|
||||
const date = new Date(isoDate + 'T12:00:00');
|
||||
if (format === 'short') {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
}).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function statusDotClass(status: DocumentStatus): string {
|
||||
switch (status) {
|
||||
case 'PLACEHOLDER':
|
||||
return 'bg-gray-400';
|
||||
case 'UPLOADED':
|
||||
return 'bg-emerald-500';
|
||||
case 'TRANSCRIBED':
|
||||
return 'bg-blue-400';
|
||||
case 'REVIEWED':
|
||||
return 'bg-amber-400';
|
||||
case 'ARCHIVED':
|
||||
return 'bg-emerald-600';
|
||||
}
|
||||
}
|
||||
|
||||
export function statusLabel(status: string): string {
|
||||
return formatDocumentStatus(status);
|
||||
}
|
||||
@@ -1,27 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
let { userInitials }: { userInitials: string | null } = $props();
|
||||
|
||||
let userMenuOpen = $state(false);
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
userMenuOpen = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative"
|
||||
{@attach clickOutside}
|
||||
use:clickOutside
|
||||
onclickoutside={() => (userMenuOpen = false)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') userMenuOpen = false;
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
interface Correspondent {
|
||||
id: string;
|
||||
@@ -17,20 +18,6 @@ interface Props {
|
||||
|
||||
let { correspondents, loading, senderName, onselect, onclose }: Props = $props();
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
onclose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getOptionElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>('[role="option"]'));
|
||||
}
|
||||
@@ -60,6 +47,7 @@ function getInitials(person: Correspondent): string {
|
||||
|
||||
<div
|
||||
use:clickOutside
|
||||
onclickoutside={onclose}
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
aria-label={m.conv_suggestions_heading()}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
/* ─── 2. Raw palette — never used directly in components ──────────────────── */
|
||||
@theme {
|
||||
/* Breakpoints */
|
||||
--breakpoint-xs: 375px;
|
||||
|
||||
/* Brand palette constants */
|
||||
--palette-navy: #012851;
|
||||
--palette-mint: #a1dcd8;
|
||||
|
||||
Reference in New Issue
Block a user