feat(transcription): add sticky review progress counter to TranscriptionEditView
Shows 'X / Y geprüft' with a brand-mint progress bar at the top of the transcription panel. Derived from the blocks prop — no extra state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,9 @@ let debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
|
|||||||
let pendingTexts = new SvelteMap<string, string>();
|
let pendingTexts = new SvelteMap<string, string>();
|
||||||
let sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
let sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||||
let hasBlocks = $derived(blocks.length > 0);
|
let hasBlocks = $derived(blocks.length > 0);
|
||||||
|
let reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
|
||||||
|
let totalCount = $derived(blocks.length);
|
||||||
|
let reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
|
||||||
|
|
||||||
function getSaveState(blockId: string): SaveState {
|
function getSaveState(blockId: string): SaveState {
|
||||||
return saveStates.get(blockId) ?? 'idle';
|
return saveStates.get(blockId) ?? 'idle';
|
||||||
@@ -263,78 +266,92 @@ $effect(() => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col overflow-y-auto bg-surface p-4">
|
<div class="flex h-full flex-col overflow-y-auto bg-surface">
|
||||||
{#if hasBlocks}
|
{#if hasBlocks}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- Sticky review progress header -->
|
||||||
<div
|
<div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2">
|
||||||
class="flex flex-col gap-3"
|
<p class="font-sans text-xs text-ink-2">
|
||||||
bind:this={listEl}
|
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
|
||||||
onpointermove={handlePointerMove}
|
</p>
|
||||||
onpointerup={handlePointerUp}
|
<div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full">
|
||||||
>
|
|
||||||
{#each sortedBlocks as block, i (block.id)}
|
|
||||||
{#if dropTargetIdx === i}
|
|
||||||
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
|
||||||
{/if}
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
<div
|
||||||
data-block-wrapper
|
class="h-full rounded-full bg-brand-mint transition-all duration-300"
|
||||||
onblur={handleBlur}
|
style="width: {reviewProgress}%"
|
||||||
onpointerdown={(e) => handleGripDown(e, block.id)}
|
></div>
|
||||||
class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
|
|
||||||
style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''}
|
|
||||||
>
|
|
||||||
<TranscriptionBlock
|
|
||||||
blockId={block.id}
|
|
||||||
documentId={documentId}
|
|
||||||
blockNumber={i + 1}
|
|
||||||
text={block.text}
|
|
||||||
label={block.label}
|
|
||||||
active={activeBlockId === block.id}
|
|
||||||
reviewed={block.reviewed ?? false}
|
|
||||||
saveState={getSaveState(block.id)}
|
|
||||||
canComment={canComment}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
onTextChange={(text) => handleTextChange(block.id, text)}
|
|
||||||
onFocus={() => handleFocus(block.id)}
|
|
||||||
onDeleteClick={() => handleDelete(block.id)}
|
|
||||||
onRetry={() => handleRetry(block.id)}
|
|
||||||
onReviewToggle={() => onReviewToggle(block.id)}
|
|
||||||
onMoveUp={() => handleMoveUp(block.id)}
|
|
||||||
onMoveDown={() => handleMoveDown(block.id)}
|
|
||||||
isFirst={i === 0}
|
|
||||||
isLast={i === sortedBlocks.length - 1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if dropTargetIdx === sortedBlocks.length}
|
|
||||||
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Next block CTA — dashed outline hint -->
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-center rounded border border-dashed border-line px-4 py-5 text-center font-sans text-sm text-ink-3"
|
|
||||||
>
|
|
||||||
{m.transcription_next_block_cta({ number: sortedBlocks.length + 1 })}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{#if canRunOcr && onTriggerOcr}
|
<div class="p-4">
|
||||||
<details class="mt-6">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<summary
|
<div
|
||||||
class="cursor-pointer font-sans text-xs font-medium text-ink-3 transition-colors hover:text-brand-navy"
|
class="flex flex-col gap-3"
|
||||||
|
bind:this={listEl}
|
||||||
|
onpointermove={handlePointerMove}
|
||||||
|
onpointerup={handlePointerUp}
|
||||||
|
>
|
||||||
|
{#each sortedBlocks as block, i (block.id)}
|
||||||
|
{#if dropTargetIdx === i}
|
||||||
|
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
||||||
|
{/if}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
data-block-wrapper
|
||||||
|
onblur={handleBlur}
|
||||||
|
onpointerdown={(e) => handleGripDown(e, block.id)}
|
||||||
|
class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
|
||||||
|
style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''}
|
||||||
>
|
>
|
||||||
{m.ocr_rerun_label()}
|
<TranscriptionBlock
|
||||||
</summary>
|
blockId={block.id}
|
||||||
<div class="mt-3 max-w-xs">
|
documentId={documentId}
|
||||||
<OcrTrigger
|
blockNumber={i + 1}
|
||||||
existingBlockCount={blocks.length}
|
text={block.text}
|
||||||
storedScriptType={storedScriptType}
|
label={block.label}
|
||||||
onTrigger={onTriggerOcr}
|
active={activeBlockId === block.id}
|
||||||
|
reviewed={block.reviewed ?? false}
|
||||||
|
saveState={getSaveState(block.id)}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
onTextChange={(text) => handleTextChange(block.id, text)}
|
||||||
|
onFocus={() => handleFocus(block.id)}
|
||||||
|
onDeleteClick={() => handleDelete(block.id)}
|
||||||
|
onRetry={() => handleRetry(block.id)}
|
||||||
|
onReviewToggle={() => onReviewToggle(block.id)}
|
||||||
|
onMoveUp={() => handleMoveUp(block.id)}
|
||||||
|
onMoveDown={() => handleMoveDown(block.id)}
|
||||||
|
isFirst={i === 0}
|
||||||
|
isLast={i === sortedBlocks.length - 1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
{/each}
|
||||||
{/if}
|
|
||||||
|
{#if dropTargetIdx === sortedBlocks.length}
|
||||||
|
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Next block CTA — dashed outline hint -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center rounded border border-dashed border-line px-4 py-5 text-center font-sans text-sm text-ink-3"
|
||||||
|
>
|
||||||
|
{m.transcription_next_block_cta({ number: sortedBlocks.length + 1 })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if canRunOcr && onTriggerOcr}
|
||||||
|
<details class="mt-6">
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer font-sans text-xs font-medium text-ink-3 transition-colors hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
{m.ocr_rerun_label()}
|
||||||
|
</summary>
|
||||||
|
<div class="mt-3 max-w-xs">
|
||||||
|
<OcrTrigger
|
||||||
|
existingBlockCount={blocks.length}
|
||||||
|
storedScriptType={storedScriptType}
|
||||||
|
onTrigger={onTriggerOcr}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
|
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const block1 = {
|
|||||||
text: 'Block eins',
|
text: 'Block eins',
|
||||||
label: null,
|
label: null,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
version: 0
|
version: 0,
|
||||||
|
source: 'MANUAL' as const,
|
||||||
|
reviewed: false
|
||||||
};
|
};
|
||||||
const block2 = {
|
const block2 = {
|
||||||
id: 'b2',
|
id: 'b2',
|
||||||
@@ -22,7 +24,9 @@ const block2 = {
|
|||||||
text: 'Block zwei',
|
text: 'Block zwei',
|
||||||
label: null,
|
label: null,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
version: 0
|
version: 0,
|
||||||
|
source: 'OCR' as const,
|
||||||
|
reviewed: true
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderView(overrides: Record<string, unknown> = {}, service = createConfirmService()) {
|
function renderView(overrides: Record<string, unknown> = {}, service = createConfirmService()) {
|
||||||
@@ -232,3 +236,23 @@ describe('TranscriptionEditView — delete block', () => {
|
|||||||
expect(onDeleteBlock).not.toHaveBeenCalled();
|
expect(onDeleteBlock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Review progress counter ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('TranscriptionEditView — review progress counter', () => {
|
||||||
|
it('shows reviewed count and total when blocks exist', async () => {
|
||||||
|
// block1: reviewed=false, block2: reviewed=true → "1 / 2 geprüft"
|
||||||
|
renderView();
|
||||||
|
await expect.element(page.getByText(/1 \/ 2 geprüft/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows 0 reviewed when no blocks are reviewed', async () => {
|
||||||
|
renderView({ blocks: [block1] }); // block1.reviewed = false
|
||||||
|
await expect.element(page.getByText(/0 \/ 1 geprüft/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show progress counter when there are no blocks', async () => {
|
||||||
|
renderView({ blocks: [] });
|
||||||
|
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user