fix(ui): upgrade global indicator from thin bar to rich progress panel

- Show full stats grid (total/success/fail/skip) + progress bar + cancel button during active run
- Compact colored banner for terminal states (completed/partial/failed/cancelled) with auto-dismiss
- Import cancelRun from translate API for functional cancel button
- fromStore() reactivity confirmed working — no store changes needed
- Hide on /translate/[id] to avoid duplication (existing behavior preserved)
This commit is contained in:
2026-05-15 22:37:25 +03:00
parent 3020b92768
commit b3572ce443

View File

@@ -1,19 +1,22 @@
<!-- #region TranslationRunGlobalIndicator [C:3] [TYPE Component] [SEMANTICS translate, progress, global, indicator] -->
<!-- @BRIEF Persistent mini progress indicator for translation runs, rendered in root layout. -->
<!-- @BRIEF Persistent rich progress panel for active translation runs, rendered in root layout. -->
<!-- @LAYER UI -->
<!-- @RELATION BINDS_TO -> [translationRunStore] -->
<!-- @UX_STATE hidden -> No active or recently completed run -->
<!-- @UX_STATE running -> Thin blue bar + "Translating X/Y" banner -->
<!-- @UX_STATE completed -> Green bar + "Completed" banner (auto-hides after 5s) -->
<!-- @UX_STATE failed -> Red bar + "Failed" banner (auto-hides after 8s) -->
<!-- @UX_FEEDBACK Click banner navigates to the translation run page -->
<!-- @UX_STATE running -> Rich panel with stats grid + progress bar + cancel button -->
<!-- @UX_STATE completed -> Compact green banner with auto-dismiss after 5s -->
<!-- @UX_STATE failed -> Compact red banner with auto-dismiss after 8s -->
<!-- @UX_STATE cancelled -> Compact gray banner with auto-dismiss after 8s -->
<!-- @UX_FEEDBACK Rich stats during active run; compact auto-dismiss for terminal states -->
<script>
import { translationRunStore } from '$lib/stores/translationRun.js';
import { translationRunStore, stopTranslationRun } from '$lib/stores/translationRun.js';
import { fromStore } from 'svelte/store';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { t } from '$lib/i18n';
import { onDestroy } from 'svelte';
import { cancelRun } from '$lib/api/translate.js';
import { addToast } from '$lib/toasts.js';
const runState = fromStore(translationRunStore);
@@ -25,6 +28,7 @@
// Terminal state auto-dismiss timers
let dismissTimer = $state(null);
let dismissed = $state(false);
let isCancelling = $state(false);
let uxState = $derived(runState.current?.uxState || 'idle');
@@ -37,21 +41,7 @@
return false;
});
let barColor = $derived.by(() => {
if (uxState === 'running' || uxState === 'inserting') return 'bg-blue-600';
if (uxState === 'completed' || uxState === 'partial') return 'bg-green-500';
if (uxState === 'failed') return 'bg-red-500';
if (uxState === 'cancelled') return 'bg-gray-400';
return 'bg-blue-600';
});
let bannerBg = $derived.by(() => {
if (uxState === 'running' || uxState === 'inserting') return 'bg-blue-600';
if (uxState === 'completed' || uxState === 'partial') return 'bg-green-500';
if (uxState === 'failed') return 'bg-red-500';
if (uxState === 'cancelled') return 'bg-gray-500';
return 'bg-blue-600';
});
let isActive = $derived(uxState === 'running' || uxState === 'inserting');
let label = $derived.by(() => {
if (uxState === 'inserting') return $t.translate?.run?.insert_phase || 'Inserting...';
@@ -63,6 +53,12 @@
return '';
});
let totalRecords = $derived(runState.current?.totalRecords || 0);
let successfulRecords = $derived(runState.current?.successfulRecords || 0);
let failedRecords = $derived(runState.current?.failedRecords || 0);
let skippedRecords = $derived(runState.current?.skippedRecords || 0);
let progressPct = $derived(runState.current?.progressPct || 0);
// Auto-dismiss terminal states after a few seconds
$effect(() => {
if (uxState === 'completed' || uxState === 'partial') {
@@ -88,52 +84,116 @@
goto(`/translate/${jobId}`);
}
}
async function handleCancel(e) {
e.stopPropagation();
const runId = runState.current?.runId;
if (!runId || isCancelling) return;
isCancelling = true;
try {
await cancelRun(runId);
addToast($t.translate?.run?.run_cancelled || 'Run cancelled', 'info');
} catch (err) {
addToast(err?.message || $t.translate?.run?.cancel_failed || 'Cancel failed', 'error');
} finally {
isCancelling = false;
}
}
</script>
{#if show}
<div
class="fixed top-0 left-0 right-0 z-[100] cursor-pointer shadow-sm"
onclick={handleClick}
role="button"
tabindex="0"
class="fixed top-0 left-0 right-0 z-[100] shadow-md"
role="status"
aria-label={label}
>
<!-- Animated thin bar -->
<div class="h-1 bg-gray-200">
<div
class="h-full {barColor} transition-all duration-500"
style="width: {Math.min(runState.current?.progressPct || 0, 100)}%"
/>
</div>
<!-- Compact stats banner -->
<div class="{bannerBg} text-white text-xs px-3 py-1 flex items-center gap-3">
<span class="font-medium">{label}</span>
{#if isActive}
<!-- ═══ RICH ACTIVE PANEL ═══ -->
<div class="bg-white border-b border-gray-200 px-4 py-3">
<div class="max-w-5xl mx-auto">
<!-- Header row: spinner + label + actions -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
<span class="text-sm font-semibold text-gray-900">{label}</span>
</div>
<div class="flex items-center gap-2">
<button
onclick={handleClick}
class="text-xs text-blue-600 hover:text-blue-800 underline"
>
{$t.common?.open || 'Open'}
</button>
<button
onclick={handleCancel}
disabled={isCancelling}
class="px-2 py-1 text-xs border border-red-300 text-red-600 rounded hover:bg-red-50 disabled:opacity-50 transition-colors"
>
{isCancelling
? ($t.translate?.run?.cancelling || 'Cancelling...')
: ($t.translate?.run?.cancel || 'Cancel')}
</button>
</div>
</div>
{#if uxState === 'running' || uxState === 'inserting'}
<span class="opacity-80">
{runState.current?.successfulRecords || 0}/{runState.current?.totalRecords || 0}
</span>
{#if (runState.current?.failedRecords || 0) > 0}
<span class="text-red-200">
{$t.translate?.run?.failed || 'Failed'}: {runState.current?.failedRecords}
</span>
{/if}
<span class="ml-auto opacity-70 hover:opacity-100 transition-opacity">
<!-- Progress bar with percentage -->
<div class="w-full bg-gray-200 rounded-full h-2.5 mb-2">
<div
class="bg-blue-600 h-2.5 rounded-full transition-all duration-500"
style="width: {Math.min(progressPct, 100)}%"
/>
</div>
<!-- Stats grid -->
<div class="grid grid-cols-4 gap-2 text-xs">
<div class="bg-gray-50 rounded px-2 py-1 text-center">
<span class="text-gray-500 block">{($t.translate?.run?.total || 'Total')}</span>
<span class="font-bold text-gray-900">{totalRecords}</span>
</div>
<div class="bg-green-50 rounded px-2 py-1 text-center">
<span class="text-green-600 block">{($t.translate?.run?.success || 'OK')}</span>
<span class="font-bold text-green-700">{successfulRecords}</span>
</div>
<div class="bg-red-50 rounded px-2 py-1 text-center">
<span class="text-red-600 block">{($t.translate?.run?.failed || 'Fail')}</span>
<span class="font-bold text-red-700">{failedRecords}</span>
</div>
<div class="bg-yellow-50 rounded px-2 py-1 text-center">
<span class="text-yellow-600 block">{($t.translate?.run?.skipped || 'Skip')}</span>
<span class="font-bold text-yellow-700">{skippedRecords}</span>
</div>
</div>
</div>
</div>
{:else}
<!-- ═══ COMPACT TERMINAL BANNER ═══ -->
<div
class="px-4 py-2 text-white text-sm flex items-center justify-between cursor-pointer"
class:bg-green-600={uxState === 'completed' || uxState === 'partial'}
class:bg-red-600={uxState === 'failed'}
class:bg-gray-500={uxState === 'cancelled'}
onclick={handleClick}
role="button"
tabindex="0"
>
<div class="flex items-center gap-2">
{#if uxState === 'completed'}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
{:else if uxState === 'partial' || uxState === 'failed'}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
{/if}
<span class="font-medium">{label}</span>
{#if uxState === 'partial'}
<span class="opacity-80">{successfulRecords}/{totalRecords}</span>
{/if}
</div>
<span class="opacity-70 hover:opacity-100 transition-opacity">
&#9654; {$t.common?.open || 'Open'}
</span>
{:else if uxState === 'partial'}
<span class="opacity-80">
{runState.current?.successfulRecords || 0}/{runState.current?.totalRecords || 0}
</span>
<span class="ml-auto opacity-70 hover:opacity-100 transition-opacity">
&#9654; {$t.common?.open || 'Open'}
</span>
{:else}
<span class="ml-auto opacity-70 hover:opacity-100 transition-opacity">
&#9654; {$t.common?.open || 'Open'}
</span>
{/if}
</div>
</div>
{/if}
</div>
{/if}
<!-- #endregion TranslationRunGlobalIndicator -->