fix(frontend): replace $t with _t pattern in translate components

Svelte 5 compiler fails to detect Proxy-based i18n store `t` as a store
in deeply nested/complex templates and in `.svelte.ts` model files,
generating `ReferenceError: $t is not defined` at runtime.

Fix: replace `$t.` references with:
- Template: `_t.` where `const _t = $derived(getT())`
- Script:   `getT()?.` direct function call

Applied to:
- translate/[id]/+page.svelte (the failing page)
- TranslationJobModel.svelte.ts ($derived.by() in `tabs`)
- 12 translate components (ConfigTabForm, ScheduleConfig,
  TranslationPreview, TargetTabForm, RunTabContent, BulkReplaceModal,
  TranslationRunProgress, TranslationRunResult, TermCorrectionPopup,
  BulkCorrectionSidebar, TranslationMetricsDashboard, CorrectionCell,
  TranslationRunGlobalIndicator)

Verified all 5 tabs render correctly in browser:
Config, Preview, Target, Run, Schedule.

Build clean, 698/698 tests pass, 0 color violations.
This commit is contained in:
2026-06-02 20:38:38 +03:00
parent ae53502ac2
commit 27435e4d92
15 changed files with 496 additions and 451 deletions

View File

@@ -10,7 +10,7 @@
<!-- @UX_FEEDBACK Toast on success/error; count badge on toggle button --> <!-- @UX_FEEDBACK Toast on success/error; count badge on toggle button -->
<!-- @UX_RECOVERY Remove individual items; retry submission --> <!-- @UX_RECOVERY Remove individual items; retry submission -->
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n/index.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js';
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
import { submitBulkCorrections, dictionaryApi } from '$lib/api/translate.js'; import { submitBulkCorrections, dictionaryApi } from '$lib/api/translate.js';
@@ -24,6 +24,9 @@
/** @type {'closed'|'collecting'|'reviewing'|'submitting'|'submitted'} */ /** @type {'closed'|'collecting'|'reviewing'|'submitting'|'submitted'} */
let uxState = $state('closed'); let uxState = $state('closed');
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
let items = $state([]); let items = $state([]);
let dictionaries = $state([]); let dictionaries = $state([]);
let selectedDictId = $state(''); let selectedDictId = $state('');
@@ -69,7 +72,7 @@
selectedDictId = dictionaries[0].id; selectedDictId = dictionaries[0].id;
} }
} catch (err) { } catch (err) {
addToast($t.translate?.common?.error + ': ' + ($t.translate?.corrections?.loading || 'Failed to load dictionaries'), 'error'); addToast(getT()?.translate?.common?.error + ': ' + (getT()?.translate?.corrections?.loading || 'Failed to load dictionaries'), 'error');
} }
} }
@@ -90,7 +93,7 @@
// Validate all items have corrected targets // Validate all items have corrected targets
const invalidItems = items.filter(i => !i.correctedTarget.trim()); const invalidItems = items.filter(i => !i.correctedTarget.trim());
if (invalidItems.length > 0) { if (invalidItems.length > 0) {
addToast($t.translate?.corrections?.submit_failed || 'All items must have a corrected target', 'error'); addToast(getT()?.translate?.corrections?.submit_failed || 'All items must have a corrected target', 'error');
return; return;
} }
@@ -115,10 +118,10 @@
const successCount = result.created_count || result.updated_count || corrections.length; const successCount = result.created_count || result.updated_count || corrections.length;
const totalCount = corrections.length; const totalCount = corrections.length;
if (successCount === totalCount) { if (successCount === totalCount) {
addToast($t.translate?.corrections?.submit_success || 'All corrections submitted', 'success'); addToast(getT()?.translate?.corrections?.submit_success || 'All corrections submitted', 'success');
} else { } else {
addToast( addToast(
($t.translate?.corrections?.submit_partial || '{success} of {total} submitted') (getT()?.translate?.corrections?.submit_partial || '{success} of {total} submitted')
.replace('{success}', successCount) .replace('{success}', successCount)
.replace('{total}', totalCount), .replace('{total}', totalCount),
'info' 'info'
@@ -126,7 +129,7 @@
} }
onSubmitted(result); onSubmitted(result);
} catch (err) { } catch (err) {
addToast(err?.message || $t.translate?.corrections?.submit_failed || 'Failed to submit corrections', 'error'); addToast(err?.message || getT()?.translate?.corrections?.submit_failed || 'Failed to submit corrections', 'error');
uxState = 'collecting'; uxState = 'collecting';
} }
} }
@@ -146,12 +149,12 @@
onclick={openSidebar} onclick={openSidebar}
class="fixed right-0 top-1/3 z-40 flex items-center gap-2 px-3 py-2 bg-primary text-white rounded-l-lg shadow-lg hover:bg-primary-hover transition-all translate-x-0" class="fixed right-0 top-1/3 z-40 flex items-center gap-2 px-3 py-2 bg-primary text-white rounded-l-lg shadow-lg hover:bg-primary-hover transition-all translate-x-0"
class:translate-x-[360px]={isOpen} class:translate-x-[360px]={isOpen}
title={$t.translate?.corrections?.title} title={_t.translate?.corrections?.title}
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg> </svg>
<span class="text-xs font-medium">{$t.translate?.corrections?.title}</span> <span class="text-xs font-medium">{_t.translate?.corrections?.title}</span>
{#if items.length > 0} {#if items.length > 0}
<span class="inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-destructive rounded-full"> <span class="inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-destructive rounded-full">
{items.length} {items.length}
@@ -167,8 +170,8 @@
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-border"> <div class="flex items-center justify-between px-4 py-3 border-b border-border">
<div> <div>
<h3 class="text-sm font-semibold text-text">{$t.translate?.corrections?.title}</h3> <h3 class="text-sm font-semibold text-text">{_t.translate?.corrections?.title}</h3>
<p class="text-xs text-text-muted">{$t.translate?.corrections?.subtitle}</p> <p class="text-xs text-text-muted">{_t.translate?.corrections?.subtitle}</p>
</div> </div>
<button onclick={closeSidebar} class="p-1 text-text-subtle hover:text-text-muted rounded hover:bg-surface-muted"> <button onclick={closeSidebar} class="p-1 text-text-subtle hover:text-text-muted rounded hover:bg-surface-muted">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -185,21 +188,21 @@
<svg class="w-12 h-12 text-text-subtle mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-12 h-12 text-text-subtle mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg> </svg>
<p class="text-sm text-text-muted">{$t.translate?.corrections?.no_items}</p> <p class="text-sm text-text-muted">{_t.translate?.corrections?.no_items}</p>
<p class="text-xs text-text-subtle mt-1">{$t.translate?.corrections?.add_hint}</p> <p class="text-xs text-text-subtle mt-1">{_t.translate?.corrections?.add_hint}</p>
</div> </div>
{:else} {:else}
{#each items as item, idx} {#each items as item, idx}
<div class="border border-border rounded-lg p-3 space-y-2"> <div class="border border-border rounded-lg p-3 space-y-2">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-xs font-medium text-text-muted">{$t.translate?.corrections?.source_term}</p> <p class="text-xs font-medium text-text-muted">{_t.translate?.corrections?.source_term}</p>
<p class="text-sm font-mono text-text truncate">{item.sourceTerm}</p> <p class="text-sm font-mono text-text truncate">{item.sourceTerm}</p>
</div> </div>
<button <button
onclick={() => removeItem(idx)} onclick={() => removeItem(idx)}
class="ml-2 p-0.5 text-text-subtle hover:text-destructive rounded" class="ml-2 p-0.5 text-text-subtle hover:text-destructive rounded"
title={$t.translate?.corrections?.remove} title={_t.translate?.corrections?.remove}
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
@@ -207,16 +210,16 @@
</button> </button>
</div> </div>
<div> <div>
<p class="text-xs font-medium text-destructive">{$t.translate?.corrections?.incorrect_target}</p> <p class="text-xs font-medium text-destructive">{_t.translate?.corrections?.incorrect_target}</p>
<p class="text-sm font-mono text-destructive truncate">{item.incorrectTarget}</p> <p class="text-sm font-mono text-destructive truncate">{item.incorrectTarget}</p>
</div> </div>
<div> <div>
<label class="text-xs font-medium text-success">{$t.translate?.corrections?.corrected_target}</label> <label class="text-xs font-medium text-success">{_t.translate?.corrections?.corrected_target}</label>
<input <input
type="text" type="text"
value={item.correctedTarget} value={item.correctedTarget}
oninput={(e) => updateCorrection(idx, e.target.value)} oninput={(e) => updateCorrection(idx, e.target.value)}
placeholder={$t.translate?.corrections?.corrected_placeholder} placeholder={_t.translate?.corrections?.corrected_placeholder}
class="w-full mt-1 px-2 py-1 text-sm border border-border-strong rounded focus:ring-2 focus:ring-success-ring focus:border-success" class="w-full mt-1 px-2 py-1 text-sm border border-border-strong rounded focus:ring-2 focus:ring-success-ring focus:border-success"
/> />
</div> </div>
@@ -227,7 +230,7 @@
{:else if uxState === 'submitting'} {:else if uxState === 'submitting'}
<div class="flex flex-col items-center justify-center py-12"> <div class="flex flex-col items-center justify-center py-12">
<div class="animate-spin w-8 h-8 border-2 border-primary-ring border-t-transparent rounded-full mb-3"></div> <div class="animate-spin w-8 h-8 border-2 border-primary-ring border-t-transparent rounded-full mb-3"></div>
<p class="text-sm text-text-muted">{$t.translate?.corrections?.submitting}</p> <p class="text-sm text-text-muted">{_t.translate?.corrections?.submitting}</p>
</div> </div>
{:else if uxState === 'submitted'} {:else if uxState === 'submitted'}
@@ -235,10 +238,10 @@
<svg class="w-12 h-12 text-success mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-12 h-12 text-success mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg> </svg>
<p class="text-sm font-medium text-text">{$t.translate?.corrections?.submit_success}</p> <p class="text-sm font-medium text-text">{_t.translate?.corrections?.submit_success}</p>
{#if submitResult} {#if submitResult}
<p class="text-xs text-text-muted mt-1"> <p class="text-xs text-text-muted mt-1">
{$t.translate?.corrections?.submit_partial {_t.translate?.corrections?.submit_partial
?.replace('{success}', submitResult.created_count || submitResult.updated_count || items.length) ?.replace('{success}', submitResult.created_count || submitResult.updated_count || items.length)
?.replace('{total}', items.length)} ?.replace('{total}', items.length)}
</p> </p>
@@ -252,12 +255,12 @@
{#if uxState === 'collecting' && items.length > 0} {#if uxState === 'collecting' && items.length > 0}
<!-- Dictionary selector --> <!-- Dictionary selector -->
<div> <div>
<label class="block text-xs font-medium text-text mb-1">{$t.translate?.corrections?.dictionary}</label> <label class="block text-xs font-medium text-text mb-1">{_t.translate?.corrections?.dictionary}</label>
<select <select
bind:value={selectedDictId} bind:value={selectedDictId}
class="w-full px-2 py-1.5 border border-border-strong rounded text-sm" class="w-full px-2 py-1.5 border border-border-strong rounded text-sm"
> >
<option value="">{$t.translate?.corrections?.select_dictionary}</option> <option value="">{_t.translate?.corrections?.select_dictionary}</option>
{#each dictionaries as dict} {#each dictionaries as dict}
<option value={dict.id}>{dict.name}</option> <option value={dict.id}>{dict.name}</option>
{/each} {/each}
@@ -269,14 +272,14 @@
disabled={!selectedDictId || validItemCount === 0} disabled={!selectedDictId || validItemCount === 0}
class="w-full px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors" class="w-full px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
{$t.translate?.corrections?.submit_all?.replace('{count}', validItemCount)} {_t.translate?.corrections?.submit_all?.replace('{count}', validItemCount)}
</button> </button>
{:else if uxState === 'submitted'} {:else if uxState === 'submitted'}
<button <button
onclick={resetAndClose} onclick={resetAndClose}
class="w-full px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary-hover transition-colors" class="w-full px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary-hover transition-colors"
> >
{$t.translate?.common?.close} {_t.translate?.common?.close}
</button> </button>
{/if} {/if}
</div> </div>

View File

@@ -15,7 +15,7 @@
<script lang="ts"> <script lang="ts">
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
import { bulkFindReplace, bulkReplacePreview } from '$lib/api/translate.js'; import { bulkFindReplace, bulkReplacePreview } from '$lib/api/translate.js';
import { t, _ } from '$lib/i18n/index.svelte.js'; import { _, getT } from '$lib/i18n/index.svelte.js';
/** @type {{ show: boolean, runId: string, targetLanguages: string[], onClose: () => void, onApplied?: (count: number) => void }} */ /** @type {{ show: boolean, runId: string, targetLanguages: string[], onClose: () => void, onApplied?: (count: number) => void }} */
let { let {
@@ -28,6 +28,9 @@
/** @type {'closed'|'configuring'|'previewing'|'preview_error'|'confirming'|'applying'|'applied'|'apply_error'} */ /** @type {'closed'|'configuring'|'previewing'|'preview_error'|'confirming'|'applying'|'applied'|'apply_error'} */
let uxState = $state('closed'); let uxState = $state('closed');
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
let findPattern = $state(''); let findPattern = $state('');
let replacementText = $state(''); let replacementText = $state('');
@@ -147,15 +150,15 @@
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={$t.translate?.bulk_replace?.title} aria-label={_t.translate?.bulk_replace?.title}
> >
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0"> <div class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 class="text-lg font-semibold text-text">{$t.translate?.bulk_replace?.title}</h2> <h2 class="text-lg font-semibold text-text">{_t.translate?.bulk_replace?.title}</h2>
<button <button
onclick={handleClose} onclick={handleClose}
class="p-1 text-text-subtle hover:text-text-muted rounded-lg hover:bg-surface-muted transition-colors" class="p-1 text-text-subtle hover:text-text-muted rounded-lg hover:bg-surface-muted transition-colors"
aria-label={$t.translate?.bulk_replace?.close} aria-label={_t.translate?.bulk_replace?.close}
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" 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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
@@ -170,12 +173,12 @@
{#if uxState === 'configuring' || uxState === 'previewing' || uxState === 'preview_error'} {#if uxState === 'configuring' || uxState === 'previewing' || uxState === 'preview_error'}
<!-- Find Pattern --> <!-- Find Pattern -->
<div> <div>
<label class="block text-sm font-medium text-text mb-1">{$t.translate?.bulk_replace?.find_pattern}</label> <label class="block text-sm font-medium text-text mb-1">{_t.translate?.bulk_replace?.find_pattern}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
type="text" type="text"
bind:value={findPattern} bind:value={findPattern}
placeholder={$t.translate?.bulk_replace?.find_placeholder} placeholder={_t.translate?.bulk_replace?.find_placeholder}
class="flex-1 px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring" class="flex-1 px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
onkeydown={(e) => e.key === 'Enter' && handlePreview()} onkeydown={(e) => e.key === 'Enter' && handlePreview()}
/> />
@@ -186,18 +189,18 @@
class="rounded" class="rounded"
/> />
<span class="text-text-muted font-mono text-xs">.*</span> <span class="text-text-muted font-mono text-xs">.*</span>
<span class="text-text-muted text-xs">{$t.translate?.bulk_replace?.regex}</span> <span class="text-text-muted text-xs">{_t.translate?.bulk_replace?.regex}</span>
</label> </label>
</div> </div>
</div> </div>
<!-- Replacement Text --> <!-- Replacement Text -->
<div> <div>
<label class="block text-sm font-medium text-text mb-1">{$t.translate?.bulk_replace?.replace_with}</label> <label class="block text-sm font-medium text-text mb-1">{_t.translate?.bulk_replace?.replace_with}</label>
<input <input
type="text" type="text"
bind:value={replacementText} bind:value={replacementText}
placeholder={$t.translate?.bulk_replace?.replace_placeholder} placeholder={_t.translate?.bulk_replace?.replace_placeholder}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
onkeydown={(e) => e.key === 'Enter' && handlePreview()} onkeydown={(e) => e.key === 'Enter' && handlePreview()}
/> />
@@ -205,12 +208,12 @@
<!-- Target Language --> <!-- Target Language -->
<div> <div>
<label class="block text-sm font-medium text-text mb-1">{$t.translate?.bulk_replace?.target_language}</label> <label class="block text-sm font-medium text-text mb-1">{_t.translate?.bulk_replace?.target_language}</label>
<select <select
bind:value={targetLanguage} bind:value={targetLanguage}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
> >
<option value="">{$t.translate?.bulk_replace?.select_language}</option> <option value="">{_t.translate?.bulk_replace?.select_language}</option>
{#each targetLanguages as lang} {#each targetLanguages as lang}
<option value={lang}>{lang}</option> <option value={lang}>{lang}</option>
{/each} {/each}
@@ -223,7 +226,7 @@
disabled={!findPattern.trim() || !targetLanguage} disabled={!findPattern.trim() || !targetLanguage}
class="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 transition-colors text-sm font-medium" class="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 transition-colors text-sm font-medium"
> >
{$t.translate?.bulk_replace?.preview_affected} {_t.translate?.bulk_replace?.preview_affected}
</button> </button>
{/if} {/if}
@@ -239,22 +242,22 @@
<div> <div>
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-text"> <h3 class="text-sm font-medium text-text">
{$t.translate?.bulk_replace?.preview_count.replace('{count}', affectedRecords.length)} {_t.translate?.bulk_replace?.preview_count.replace('{count}', affectedRecords.length)}
</h3> </h3>
<button <button
onclick={handleShowConfirm} onclick={handleShowConfirm}
class="px-4 py-1.5 bg-warning text-white rounded-lg hover:bg-warning transition-colors text-sm font-medium" class="px-4 py-1.5 bg-warning text-white rounded-lg hover:bg-warning transition-colors text-sm font-medium"
> >
{$t.translate?.bulk_replace?.apply_changes} {_t.translate?.bulk_replace?.apply_changes}
</button> </button>
</div> </div>
<div class="max-h-60 overflow-y-auto border border-border rounded-lg"> <div class="max-h-60 overflow-y-auto border border-border rounded-lg">
<table class="min-w-full divide-y divide-border text-xs"> <table class="min-w-full divide-y divide-border text-xs">
<thead class="bg-surface-muted sticky top-0"> <thead class="bg-surface-muted sticky top-0">
<tr> <tr>
<th class="px-3 py-2 text-left font-medium text-text-muted uppercase">{$t.translate?.bulk_replace?.table_number}</th> <th class="px-3 py-2 text-left font-medium text-text-muted uppercase">{_t.translate?.bulk_replace?.table_number}</th>
<th class="px-3 py-2 text-left font-medium text-text-muted uppercase">{$t.translate?.bulk_replace?.table_before}</th> <th class="px-3 py-2 text-left font-medium text-text-muted uppercase">{_t.translate?.bulk_replace?.table_before}</th>
<th class="px-3 py-2 text-left font-medium text-text-muted uppercase">{$t.translate?.bulk_replace?.table_after}</th> <th class="px-3 py-2 text-left font-medium text-text-muted uppercase">{_t.translate?.bulk_replace?.table_after}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-border"> <tbody class="divide-y divide-border">
@@ -269,7 +272,7 @@
</table> </table>
{#if affectedRecords.length > 100} {#if affectedRecords.length > 100}
<div class="px-3 py-2 text-xs text-text-subtle bg-surface-muted border-t border-border"> <div class="px-3 py-2 text-xs text-text-subtle bg-surface-muted border-t border-border">
{$t.translate?.bulk_replace?.showing_first.replace('{shown}', 100).replace('{total}', affectedRecords.length)} {_t.translate?.bulk_replace?.showing_first.replace('{shown}', 100).replace('{total}', affectedRecords.length)}
</div> </div>
{/if} {/if}
</div> </div>
@@ -278,8 +281,8 @@
{#if uxState === 'previewing' && affectedRecords.length === 0} {#if uxState === 'previewing' && affectedRecords.length === 0}
<div class="bg-surface-muted border border-border rounded-lg p-6 text-center"> <div class="bg-surface-muted border border-border rounded-lg p-6 text-center">
<p class="text-sm text-text-muted">{$t.translate?.bulk_replace?.no_matching}</p> <p class="text-sm text-text-muted">{_t.translate?.bulk_replace?.no_matching}</p>
<p class="text-xs text-text-subtle mt-1">{$t.translate?.bulk_replace?.no_matching_hint}</p> <p class="text-xs text-text-subtle mt-1">{_t.translate?.bulk_replace?.no_matching_hint}</p>
</div> </div>
{/if} {/if}
@@ -292,22 +295,22 @@
</svg> </svg>
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium text-warning"> <p class="text-sm font-medium text-warning">
{$t.translate?.bulk_replace?.confirm_title.replace('{count}', applyCount)} {_t.translate?.bulk_replace?.confirm_title.replace('{count}', applyCount)}
</p> </p>
<p class="text-xs text-warning mt-1"> <p class="text-xs text-warning mt-1">
{$t.translate?.bulk_replace?.confirm_body.replace('{language}', targetLanguage)} {_t.translate?.bulk_replace?.confirm_body.replace('{language}', targetLanguage)}
</p> </p>
<!-- Submit to Dictionary checkbox --> <!-- Submit to Dictionary checkbox -->
<div class="mt-3 space-y-2"> <div class="mt-3 space-y-2">
<label class="flex items-center gap-2 text-sm text-text cursor-pointer"> <label class="flex items-center gap-2 text-sm text-text cursor-pointer">
<input type="checkbox" bind:checked={submitToDict} class="rounded" /> <input type="checkbox" bind:checked={submitToDict} class="rounded" />
<span>{$t.translate?.bulk_replace?.submit_to_dict}</span> <span>{_t.translate?.bulk_replace?.submit_to_dict}</span>
</label> </label>
{#if submitToDict} {#if submitToDict}
<textarea <textarea
bind:value={usageNotes} bind:value={usageNotes}
placeholder={$t.translate?.bulk_replace?.usage_notes_placeholder} placeholder={_t.translate?.bulk_replace?.usage_notes_placeholder}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
rows="2" rows="2"
></textarea> ></textarea>
@@ -319,13 +322,13 @@
onclick={handleCancelConfirm} onclick={handleCancelConfirm}
class="px-3 py-1.5 text-sm border border-border-strong text-text rounded-lg hover:bg-surface-card transition-colors" class="px-3 py-1.5 text-sm border border-border-strong text-text rounded-lg hover:bg-surface-card transition-colors"
> >
{$t.translate?.bulk_replace?.cancel} {_t.translate?.bulk_replace?.cancel}
</button> </button>
<button <button
onclick={handleApply} onclick={handleApply}
class="px-3 py-1.5 text-sm bg-warning text-white rounded-lg hover:bg-warning transition-colors" class="px-3 py-1.5 text-sm bg-warning text-white rounded-lg hover:bg-warning transition-colors"
> >
{$t.translate?.bulk_replace?.apply_count.replace('{count}', applyCount)} {_t.translate?.bulk_replace?.apply_count.replace('{count}', applyCount)}
</button> </button>
</div> </div>
</div> </div>
@@ -337,7 +340,7 @@
{#if uxState === 'applying'} {#if uxState === 'applying'}
<div class="flex items-center justify-center gap-3 py-8"> <div class="flex items-center justify-center gap-3 py-8">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-warning" /> <div class="animate-spin rounded-full h-6 w-6 border-b-2 border-warning" />
<span class="text-sm text-text-muted">{$t.translate?.bulk_replace?.applying}</span> <span class="text-sm text-text-muted">{_t.translate?.bulk_replace?.applying}</span>
</div> </div>
{/if} {/if}
@@ -347,13 +350,13 @@
<svg class="w-12 h-12 text-success mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-12 h-12 text-success mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<p class="text-sm font-medium text-success">{$t.translate?.bulk_replace?.applied_title}</p> <p class="text-sm font-medium text-success">{_t.translate?.bulk_replace?.applied_title}</p>
<p class="text-xs text-success mt-1">{$t.translate?.bulk_replace?.applied_body.replace('{count}', applyCount)}</p> <p class="text-xs text-success mt-1">{_t.translate?.bulk_replace?.applied_body.replace('{count}', applyCount)}</p>
<button <button
onclick={handleClose} onclick={handleClose}
class="mt-4 px-4 py-2 bg-success text-white rounded-lg hover:bg-success transition-colors text-sm" class="mt-4 px-4 py-2 bg-success text-white rounded-lg hover:bg-success transition-colors text-sm"
> >
{$t.translate?.bulk_replace?.done} {_t.translate?.bulk_replace?.done}
</button> </button>
</div> </div>
{/if} {/if}
@@ -367,13 +370,13 @@
onclick={handleShowConfirm} onclick={handleShowConfirm}
class="px-3 py-1.5 text-sm bg-destructive text-white rounded-lg hover:bg-destructive-hover transition-colors" class="px-3 py-1.5 text-sm bg-destructive text-white rounded-lg hover:bg-destructive-hover transition-colors"
> >
{$t.translate?.bulk_replace?.retry} {_t.translate?.bulk_replace?.retry}
</button> </button>
<button <button
onclick={handleClose} onclick={handleClose}
class="px-3 py-1.5 text-sm border border-border-strong text-text rounded-lg hover:bg-surface-muted transition-colors" class="px-3 py-1.5 text-sm border border-border-strong text-text rounded-lg hover:bg-surface-muted transition-colors"
> >
{$t.translate?.bulk_replace?.cancel} {_t.translate?.bulk_replace?.cancel}
</button> </button>
</div> </div>
</div> </div>
@@ -387,7 +390,7 @@
onclick={handleClose} onclick={handleClose}
class="px-4 py-2 text-sm text-text bg-surface-muted rounded-lg hover:bg-surface-muted transition-colors" class="px-4 py-2 text-sm text-text bg-surface-muted rounded-lg hover:bg-surface-muted transition-colors"
> >
{$t.translate?.bulk_replace?.close} {_t.translate?.bulk_replace?.close}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -19,7 +19,7 @@
<!-- @UX_REACTIVITY Props -> $bindable() for all form fields, $props() for environment list --> <!-- @UX_REACTIVITY Props -> $bindable() for all form fields, $props() for environment list -->
<!-- @UX_REACTIVITY LocalState -> datasourceList, datasourceLoading, showDatasourceDropdown --> <!-- @UX_REACTIVITY LocalState -> datasourceList, datasourceLoading, showDatasourceDropdown -->
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n/index.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js';
import { ALL_LANGUAGES } from '$lib/i18n/languages.js'; import { ALL_LANGUAGES } from '$lib/i18n/languages.js';
import { fetchDatasources, fetchDatasourceColumns } from '$lib/api/translate.js'; import { fetchDatasources, fetchDatasourceColumns } from '$lib/api/translate.js';
import MultiSelect from '$lib/components/ui/MultiSelect.svelte'; import MultiSelect from '$lib/components/ui/MultiSelect.svelte';
@@ -59,6 +59,9 @@
let showDatasourceDropdown = $state(false); let showDatasourceDropdown = $state(false);
let isColumnsLoading = $state(false); let isColumnsLoading = $state(false);
let columnList = $derived(availableColumns); let columnList = $derived(availableColumns);
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
// ---- Derived ---- // ---- Derived ----
let logicalColumns = $derived(availableColumns.filter(c => c.is_physical !== false)); let logicalColumns = $derived(availableColumns.filter(c => c.is_physical !== false));
@@ -156,18 +159,18 @@
<div class="space-y-6"> <div class="space-y-6">
<!-- Basic Info --> <!-- Basic Info -->
<section class="bg-surface-card border border-border rounded-lg p-6"> <section class="bg-surface-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold text-text mb-4">{$t.translate?.config?.basic_info}</h2> <h2 class="text-lg font-semibold text-text mb-4">{_t.translate?.config?.basic_info}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.name} <span class="text-destructive">*</span> {_t.translate?.config?.name} <span class="text-destructive">*</span>
<HelpTooltip text={$t.translate?.config?.help_name || ''} /> <HelpTooltip text={_t.translate?.config?.help_name || ''} />
</label> </label>
<input <input
type="text" type="text"
bind:value={name} bind:value={name}
class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.name ? 'border-destructive-ring bg-destructive-light' : 'border-border-strong'} focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring" class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.name ? 'border-destructive-ring bg-destructive-light' : 'border-border-strong'} focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
placeholder={$t.translate?.config?.name_placeholder} placeholder={_t.translate?.config?.name_placeholder}
/> />
{#if validationErrors.name} {#if validationErrors.name}
<p class="text-xs text-destructive mt-1">{validationErrors.name}</p> <p class="text-xs text-destructive mt-1">{validationErrors.name}</p>
@@ -175,14 +178,14 @@
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.description} {_t.translate?.config?.description}
<HelpTooltip text={$t.translate?.config?.help_description || ''} /> <HelpTooltip text={_t.translate?.config?.help_description || ''} />
</label> </label>
<textarea <textarea
bind:value={description} bind:value={description}
rows="2" rows="2"
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
placeholder={$t.translate?.config?.description_placeholder} placeholder={_t.translate?.config?.description_placeholder}
/> />
</div> </div>
</div> </div>
@@ -190,19 +193,19 @@
<!-- Datasource & Environment --> <!-- Datasource & Environment -->
<section class="bg-surface-card border border-border rounded-lg p-6"> <section class="bg-surface-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold text-text mb-4">{$t.translate?.config?.datasource}</h2> <h2 class="text-lg font-semibold text-text mb-4">{_t.translate?.config?.datasource}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.environment} {_t.translate?.config?.environment}
<HelpTooltip text={$t.translate?.config?.help_environment || ''} /> <HelpTooltip text={_t.translate?.config?.help_environment || ''} />
</label> </label>
<select <select
bind:value={environmentId} bind:value={environmentId}
onchange={onEnvChange} onchange={onEnvChange}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
> >
<option value="">{$t.translate?.config?.select_environment}</option> <option value="">{_t.translate?.config?.select_environment}</option>
{#each environments as env} {#each environments as env}
<option value={env.id}>{env.name} ({env.id})</option> <option value={env.id}>{env.name} ({env.id})</option>
{/each} {/each}
@@ -210,8 +213,8 @@
</div> </div>
<div class="relative"> <div class="relative">
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.datasource_id} {_t.translate?.config?.datasource_id}
<HelpTooltip text={$t.translate?.config?.help_datasource || ''} /> <HelpTooltip text={_t.translate?.config?.help_datasource || ''} />
</label> </label>
<input <input
type="text" type="text"
@@ -220,7 +223,7 @@
onfocus={() => searchDatasources('')} onfocus={() => searchDatasources('')}
onblur={() => setTimeout(() => (showDatasourceDropdown = false), 150)} onblur={() => setTimeout(() => (showDatasourceDropdown = false), 150)}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
placeholder={$t.translate?.config?.datasource_search_placeholder || 'Search datasets...'} placeholder={_t.translate?.config?.datasource_search_placeholder || 'Search datasets...'}
/> />
{#if datasourceLoading} {#if datasourceLoading}
<div class="absolute right-3 top-9 animate-spin h-4 w-4 border-2 border-primary-ring border-t-transparent rounded-full"></div> <div class="absolute right-3 top-9 animate-spin h-4 w-4 border-2 border-primary-ring border-t-transparent rounded-full"></div>
@@ -244,7 +247,7 @@
{#if databaseDialect} {#if databaseDialect}
<div class="md:col-span-2"> <div class="md:col-span-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-light text-success"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-light text-success">
{$t.translate?.config?.detected_dialect.replace('{dialect}', databaseDialect)} {_t.translate?.config?.detected_dialect.replace('{dialect}', databaseDialect)}
</span> </span>
</div> </div>
{/if} {/if}
@@ -257,7 +260,7 @@
{#if columnList.length > 0} {#if columnList.length > 0}
<div class="mt-4"> <div class="mt-4">
<label class="block text-sm font-medium text-text mb-2"> <label class="block text-sm font-medium text-text mb-2">
{$t.translate?.config?.available_columns.replace('{count}', columnList.length)} {_t.translate?.config?.available_columns.replace('{count}', columnList.length)}
</label> </label>
<div class="max-h-40 overflow-y-auto border border-border rounded-lg divide-y divide-border"> <div class="max-h-40 overflow-y-auto border border-border rounded-lg divide-y divide-border">
{#each columnList as col} {#each columnList as col}
@@ -267,7 +270,7 @@
<span class="text-xs text-text-subtle">({col.type})</span> <span class="text-xs text-text-subtle">({col.type})</span>
{/if} {/if}
{#if isVirtual(col.name)} {#if isVirtual(col.name)}
<span class="text-xs text-warning italic">{$t.translate?.config?.virtual}</span> <span class="text-xs text-warning italic">{_t.translate?.config?.virtual}</span>
{/if} {/if}
</div> </div>
{/each} {/each}
@@ -278,21 +281,21 @@
<!-- Column Mapping --> <!-- Column Mapping -->
<section class="bg-surface-card border border-border rounded-lg p-6"> <section class="bg-surface-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold text-text mb-4">{$t.translate?.config?.column_mapping}</h2> <h2 class="text-lg font-semibold text-text mb-4">{_t.translate?.config?.column_mapping}</h2>
<!-- Translation Column --> <!-- Translation Column -->
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.translation_column} <span class="text-destructive">*</span> {_t.translate?.config?.translation_column} <span class="text-destructive">*</span>
<HelpTooltip text={$t.translate?.config?.help_translation_column || ''} /> <HelpTooltip text={_t.translate?.config?.help_translation_column || ''} />
</label> </label>
<select <select
bind:value={translationColumn} bind:value={translationColumn}
class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.translationColumn ? 'border-destructive-ring bg-destructive-light' : 'border-border-strong'} focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring" class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.translationColumn ? 'border-destructive-ring bg-destructive-light' : 'border-border-strong'} focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
> >
<option value="">{$t.translate?.config?.select_column}</option> <option value="">{_t.translate?.config?.select_column}</option>
{#each columnList as col} {#each columnList as col}
<option value={col.name}>{col.name}{isVirtual(col.name) ? ` (${$t.translate?.config?.virtual})` : ''}</option> <option value={col.name}>{col.name}{isVirtual(col.name) ? ` (${_t.translate?.config?.virtual})` : ''}</option>
{/each} {/each}
</select> </select>
{#if validationErrors.translationColumn} {#if validationErrors.translationColumn}
@@ -304,15 +307,15 @@
<div class="mb-4"> <div class="mb-4">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-text flex items-center gap-1"> <label class="block text-sm font-medium text-text flex items-center gap-1">
{$t.translate?.config?.key_columns} {_t.translate?.config?.key_columns}
<HelpTooltip text={$t.translate?.config?.help_key_columns || ''} /> <HelpTooltip text={_t.translate?.config?.help_key_columns || ''} />
</label> </label>
{#if sourceKeyCols.length > 0} {#if sourceKeyCols.length > 0}
<button <button
onclick={() => { sourceKeyCols = []; targetKeyCols = []; }} onclick={() => { sourceKeyCols = []; targetKeyCols = []; }}
class="text-xs text-destructive hover:text-destructive" class="text-xs text-destructive hover:text-destructive"
> >
{$t.translate?.config?.clear_all} {_t.translate?.config?.clear_all}
</button> </button>
{/if} {/if}
</div> </div>
@@ -326,7 +329,7 @@
}} }}
class="px-3 py-1 text-xs border border-dashed border-border-strong rounded text-text-muted hover:border-primary-ring hover:text-primary" class="px-3 py-1 text-xs border border-dashed border-border-strong rounded text-text-muted hover:border-primary-ring hover:text-primary"
> >
{$t.translate?.config?.add_key_column} {_t.translate?.config?.add_key_column}
</button> </button>
</div> </div>
{:else} {:else}
@@ -338,7 +341,7 @@
{sourceKeyCols.includes(col.name) {sourceKeyCols.includes(col.name)
? 'bg-primary-light border-primary-ring text-primary' ? 'bg-primary-light border-primary-ring text-primary'
: 'bg-surface-card border-border text-text-muted hover:border-border-strong'}" : 'bg-surface-card border-border text-text-muted hover:border-border-strong'}"
title={isVirtual(col.name) ? $t.translate?.config?.virtual_column_warning.replace('{col}', col.name) : ''} title={isVirtual(col.name) ? _t.translate?.config?.virtual_column_warning.replace('{col}', col.name) : ''}
> >
{col.name} {col.name}
{#if isVirtual(col.name)} {#if isVirtual(col.name)}
@@ -363,7 +366,7 @@
<input <input
type="text" type="text"
value={targetKeyCols[idx] || ''} value={targetKeyCols[idx] || ''}
placeholder={$t.translate?.config?.target_column_placeholder} placeholder={_t.translate?.config?.target_column_placeholder}
oninput={(e) => updateTargetKeyCol(idx, e.target.value)} oninput={(e) => updateTargetKeyCol(idx, e.target.value)}
class="flex-1 px-3 py-1.5 border border-border-strong rounded text-sm font-mono focus-visible:ring-2 focus-visible:ring-primary-ring" class="flex-1 px-3 py-1.5 border border-border-strong rounded text-sm font-mono focus-visible:ring-2 focus-visible:ring-primary-ring"
/> />
@@ -390,11 +393,11 @@
<!-- Context Columns --> <!-- Context Columns -->
<div> <div>
<label class="block text-sm font-medium text-text mb-2 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-2 flex items-center gap-1">
{$t.translate?.config?.context_columns} {_t.translate?.config?.context_columns}
<HelpTooltip text={$t.translate?.config?.help_context_columns || ''} /> <HelpTooltip text={_t.translate?.config?.help_context_columns || ''} />
</label> </label>
{#if columnList.length === 0} {#if columnList.length === 0}
<p class="text-xs text-text-subtle">{$t.translate?.config?.select_datasource_hint}</p> <p class="text-xs text-text-subtle">{_t.translate?.config?.select_datasource_hint}</p>
{:else} {:else}
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each columnList as col} {#each columnList as col}
@@ -418,15 +421,15 @@
<!-- LLM Settings --> <!-- LLM Settings -->
<section class="bg-surface-card border border-border rounded-lg p-6"> <section class="bg-surface-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold text-text mb-4">{$t.translate?.config?.llm_settings}</h2> <h2 class="text-lg font-semibold text-text mb-4">{_t.translate?.config?.llm_settings}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.provider} {_t.translate?.config?.provider}
<HelpTooltip text={$t.translate?.config?.help_provider || ''} /> <HelpTooltip text={_t.translate?.config?.help_provider || ''} />
</label> </label>
<select bind:value={providerId} class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"> <select bind:value={providerId} class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm">
<option value="">{$t.translate?.config?.select_provider}</option> <option value="">{_t.translate?.config?.select_provider}</option>
{#each llmProviders as provider} {#each llmProviders as provider}
<option value={provider.id}>{provider.name || provider.provider_type}</option> <option value={provider.id}>{provider.name || provider.provider_type}</option>
{/each} {/each}
@@ -434,20 +437,20 @@
</div> </div>
<div> <div>
<div class="flex items-center gap-1 mb-1"> <div class="flex items-center gap-1 mb-1">
<span class="text-sm font-medium text-text">{$t.translate?.config?.target_language}</span> <span class="text-sm font-medium text-text">{_t.translate?.config?.target_language}</span>
<span class="text-destructive">*</span> <span class="text-destructive">*</span>
<HelpTooltip text={$t.translate?.config?.help_target_language || ''} /> <HelpTooltip text={_t.translate?.config?.help_target_language || ''} />
</div> </div>
<MultiSelect <MultiSelect
label="" label=""
options={ALL_LANGUAGES} options={ALL_LANGUAGES}
bind:selected={targetLanguages} bind:selected={targetLanguages}
searchable={true} searchable={true}
placeholder={$t.translate?.config?.target_language_search_placeholder || 'Search languages...'} placeholder={_t.translate?.config?.target_language_search_placeholder || 'Search languages...'}
required={false} required={false}
/> />
{#if targetLanguages.length === 0} {#if targetLanguages.length === 0}
<p class="text-xs text-warning mt-1">{$t.translate?.config?.target_language_required || 'Select at least one target language'}</p> <p class="text-xs text-warning mt-1">{_t.translate?.config?.target_language_required || 'Select at least one target language'}</p>
{/if} {/if}
</div> </div>
</div> </div>
@@ -462,11 +465,11 @@
/> />
<div> <div>
<span class="text-sm font-medium text-text flex items-center gap-1"> <span class="text-sm font-medium text-text flex items-center gap-1">
{$t.translate?.config?.disable_reasoning || 'Disable reasoning (save tokens)'} {_t.translate?.config?.disable_reasoning || 'Disable reasoning (save tokens)'}
<HelpTooltip text={$t.translate?.config?.help_disable_reasoning || ''} /> <HelpTooltip text={_t.translate?.config?.help_disable_reasoning || ''} />
</span> </span>
<p class="text-xs text-text-subtle mt-0.5"> <p class="text-xs text-text-subtle mt-0.5">
{$t.translate?.config?.disable_reasoning_hint || 'Saves output tokens by suppressing Chain of Thought reasoning'} {_t.translate?.config?.disable_reasoning_hint || 'Saves output tokens by suppressing Chain of Thought reasoning'}
</p> </p>
</div> </div>
</label> </label>
@@ -476,12 +479,12 @@
<!-- Dictionary Attachment --> <!-- Dictionary Attachment -->
<section class="bg-surface-card border border-border rounded-lg p-6"> <section class="bg-surface-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold text-text mb-4 flex items-center gap-2"> <h2 class="text-lg font-semibold text-text mb-4 flex items-center gap-2">
{$t.translate?.config?.terminology_dictionaries} {_t.translate?.config?.terminology_dictionaries}
<HelpTooltip text={$t.translate?.config?.help_dictionaries || ''} /> <HelpTooltip text={_t.translate?.config?.help_dictionaries || ''} />
</h2> </h2>
{#if availableDictionaries.length === 0} {#if availableDictionaries.length === 0}
<p class="text-sm text-text-subtle"> <p class="text-sm text-text-subtle">
{$t.translate?.config?.no_dictionaries} {_t.translate?.config?.no_dictionaries}
</p> </p>
{:else} {:else}
<div class="space-y-2"> <div class="space-y-2">

View File

@@ -15,7 +15,7 @@
<script lang="ts"> <script lang="ts">
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
import { inlineEditCorrection, submitCorrectionToDict, dictionaryApi } from '$lib/api/translate.js'; import { inlineEditCorrection, submitCorrectionToDict, dictionaryApi } from '$lib/api/translate.js';
import { t, _ } from '$lib/i18n/index.svelte.js'; import { _, getT } from '$lib/i18n/index.svelte.js';
/** @type {{ value: string, recordId: string, languageCode: string, runId: string, sourceLanguage?: string, contextData?: object|null, usageNotes?: string, onSave?: (newValue: string) => void }} */ /** @type {{ value: string, recordId: string, languageCode: string, runId: string, sourceLanguage?: string, contextData?: object|null, usageNotes?: string, onSave?: (newValue: string) => void }} */
let { let {
@@ -31,6 +31,9 @@
/** @type {'view'|'editing'|'saving'|'saved'|'submitting_to_dict'|'dict_submitted'|'error'} */ /** @type {'view'|'editing'|'saving'|'saved'|'submitting_to_dict'|'dict_submitted'|'error'} */
let uxState = $state('view'); let uxState = $state('view');
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
let editValue = $state(value); let editValue = $state(value);
let displayValue = $state(value); let displayValue = $state(value);
let errorMessage = $state(''); let errorMessage = $state('');
@@ -167,14 +170,14 @@
<button <button
onclick={saveEdit} onclick={saveEdit}
class="px-2 py-0.5 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors" class="px-2 py-0.5 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors"
title={$t.translate?.corrections?.cell_save} title={_t.translate?.corrections?.cell_save}
> >
</button> </button>
<button <button
onclick={cancelEditing} onclick={cancelEditing}
class="px-2 py-0.5 text-xs border border-border-strong text-text-muted rounded hover:bg-surface-muted transition-colors" class="px-2 py-0.5 text-xs border border-border-strong text-text-muted rounded hover:bg-surface-muted transition-colors"
title={$t.translate?.corrections?.cell_cancel} title={_t.translate?.corrections?.cell_cancel}
> >
</button> </button>
@@ -185,21 +188,21 @@
{:else if uxState === 'saving'} {:else if uxState === 'saving'}
<div class="flex items-center gap-2 px-2 py-1"> <div class="flex items-center gap-2 px-2 py-1">
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary-ring" /> <div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary-ring" />
<span class="text-xs text-text-muted">{$t.translate?.corrections?.cell_saving}</span> <span class="text-xs text-text-muted">{_t.translate?.corrections?.cell_saving}</span>
</div> </div>
<!-- Saved mode: show value + Dictionary submit button --> <!-- Saved mode: show value + Dictionary submit button -->
{:else if uxState === 'saved'} {:else if uxState === 'saved'}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm text-text">{displayValue}</span> <span class="text-sm text-text">{displayValue}</span>
<span class="text-xs text-success" title={$t.translate?.corrections?.cell_save_success} aria-label={$t.translate?.corrections?.cell_save_success} role="img">📝</span> <span class="text-xs text-success" title={_t.translate?.corrections?.cell_save_success} aria-label={_t.translate?.corrections?.cell_save_success} role="img">📝</span>
<button <button
onclick={openDictPopup} onclick={openDictPopup}
class="ml-1 px-1.5 py-0.5 text-xs bg-warning-light text-warning rounded hover:bg-warning-light transition-colors" class="ml-1 px-1.5 py-0.5 text-xs bg-warning-light text-warning rounded hover:bg-warning-light transition-colors"
title={$t.translate?.corrections?.cell_submit_to_dict} title={_t.translate?.corrections?.cell_submit_to_dict}
aria-label={$t.translate?.corrections?.cell_submit_to_dict} aria-label={_t.translate?.corrections?.cell_submit_to_dict}
> >
📕 {$t.translate?.corrections?.cell_submit_to_dict} 📕 {_t.translate?.corrections?.cell_submit_to_dict}
</button> </button>
</div> </div>
@@ -207,14 +210,14 @@
{:else if uxState === 'submitting_to_dict'} {:else if uxState === 'submitting_to_dict'}
<div class="flex items-center gap-2 px-2 py-1"> <div class="flex items-center gap-2 px-2 py-1">
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-warning" /> <div class="animate-spin rounded-full h-3 w-3 border-b-2 border-warning" />
<span class="text-xs text-warning">{$t.translate?.corrections?.cell_submitting}</span> <span class="text-xs text-warning">{_t.translate?.corrections?.cell_submitting}</span>
</div> </div>
<!-- Dictionary submitted mode --> <!-- Dictionary submitted mode -->
{:else if uxState === 'dict_submitted'} {:else if uxState === 'dict_submitted'}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm text-text">{displayValue}</span> <span class="text-sm text-text">{displayValue}</span>
<span class="text-xs text-success font-medium" title={$t.translate?.corrections?.cell_dict_submitted} aria-label={$t.translate?.corrections?.cell_dict_submitted} role="img">{$t.translate?.corrections?.cell_submitted_badge}</span> <span class="text-xs text-success font-medium" title={_t.translate?.corrections?.cell_dict_submitted} aria-label={_t.translate?.corrections?.cell_dict_submitted} role="img">{_t.translate?.corrections?.cell_submitted_badge}</span>
</div> </div>
<!-- Error mode --> <!-- Error mode -->
@@ -226,7 +229,7 @@
onclick={startEditing} onclick={startEditing}
class="px-2 py-0.5 text-xs bg-destructive-light text-destructive rounded hover:bg-destructive-light transition-colors self-start" class="px-2 py-0.5 text-xs bg-destructive-light text-destructive rounded hover:bg-destructive-light transition-colors self-start"
> >
{$t.translate?.corrections?.cell_retry} {_t.translate?.corrections?.cell_retry}
</button> </button>
</div> </div>
{/if} {/if}
@@ -238,24 +241,24 @@
onclick={handleCloseDictPopup} onclick={handleCloseDictPopup}
> >
<div class="bg-surface-card rounded-lg shadow-xl p-6 w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto" onclick={(e) => e.stopPropagation()}> <div class="bg-surface-card rounded-lg shadow-xl p-6 w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto" onclick={(e) => e.stopPropagation()}>
<h3 class="text-sm font-semibold text-text mb-4">{$t.translate?.corrections?.cell_submit_to_dict}</h3> <h3 class="text-sm font-semibold text-text mb-4">{_t.translate?.corrections?.cell_submit_to_dict}</h3>
<div class="space-y-3"> <div class="space-y-3">
{#if sourceLanguage} {#if sourceLanguage}
<div class="text-xs text-text-muted"> <div class="text-xs text-text-muted">
<span class="font-medium">{$t.translate?.corrections?.cell_language_pair}</span> {sourceLanguage}{languageCode} <span class="font-medium">{_t.translate?.corrections?.cell_language_pair}</span> {sourceLanguage}{languageCode}
</div> </div>
{/if} {/if}
<div> <div>
<label class="block text-xs font-medium text-text mb-1">{$t.translate?.corrections?.cell_corrected_value}</label> <label class="block text-xs font-medium text-text mb-1">{_t.translate?.corrections?.cell_corrected_value}</label>
<div class="px-3 py-2 bg-surface-muted border border-border rounded text-sm">{editValue}</div> <div class="px-3 py-2 bg-surface-muted border border-border rounded text-sm">{editValue}</div>
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-text mb-1">{$t.translate?.corrections?.dictionary}</label> <label class="block text-xs font-medium text-text mb-1">{_t.translate?.corrections?.dictionary}</label>
<select <select
bind:value={selectedDictId} bind:value={selectedDictId}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
> >
<option value="">{$t.translate?.corrections?.cell_select_dictionary}</option> <option value="">{_t.translate?.corrections?.cell_select_dictionary}</option>
{#each dictionaries as dict} {#each dictionaries as dict}
<option value={dict.id}>{dict.name}</option> <option value={dict.id}>{dict.name}</option>
{/each} {/each}
@@ -266,12 +269,12 @@
{#if editableContextData || contextEditMode} {#if editableContextData || contextEditMode}
<div class="border border-border rounded-lg p-3 bg-surface-muted"> <div class="border border-border rounded-lg p-3 bg-surface-muted">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-text">{$t.translate?.corrections?.cell_context_data}</span> <span class="text-xs font-medium text-text">{_t.translate?.corrections?.cell_context_data}</span>
<button <button
onclick={() => { contextEditMode = !contextEditMode; if (!contextEditMode) editableContextData = contextData ? JSON.parse(JSON.stringify(contextData)) : null; }} onclick={() => { contextEditMode = !contextEditMode; if (!contextEditMode) editableContextData = contextData ? JSON.parse(JSON.stringify(contextData)) : null; }}
class="text-xs text-primary hover:text-primary" class="text-xs text-primary hover:text-primary"
> >
{contextEditMode ? $t.translate?.corrections?.cell_cancel_edit : $t.translate?.corrections?.cell_edit} {contextEditMode ? _t.translate?.corrections?.cell_cancel_edit : _t.translate?.corrections?.cell_edit}
</button> </button>
</div> </div>
{#if contextEditMode} {#if contextEditMode}
@@ -279,7 +282,7 @@
bind:value={editableContextData} bind:value={editableContextData}
class="w-full px-2 py-1.5 border border-border-strong rounded text-xs font-mono" class="w-full px-2 py-1.5 border border-border-strong rounded text-xs font-mono"
rows="3" rows="3"
placeholder={$t.translate?.corrections?.cell_context_json_placeholder} placeholder={_t.translate?.corrections?.cell_context_json_placeholder}
></textarea> ></textarea>
{:else if typeof editableContextData === 'object' && editableContextData !== null} {:else if typeof editableContextData === 'object' && editableContextData !== null}
<div class="text-xs text-text-muted space-y-1"> <div class="text-xs text-text-muted space-y-1">
@@ -288,19 +291,19 @@
{/each} {/each}
</div> </div>
{:else} {:else}
<p class="text-xs text-text-subtle italic">{$t.translate?.corrections?.cell_no_context_data}</p> <p class="text-xs text-text-subtle italic">{_t.translate?.corrections?.cell_no_context_data}</p>
{/if} {/if}
</div> </div>
{/if} {/if}
<!-- Usage Notes Section (T128) --> <!-- Usage Notes Section (T128) -->
<div> <div>
<label class="block text-xs font-medium text-text mb-1">{$t.translate?.corrections?.cell_usage_notes}</label> <label class="block text-xs font-medium text-text mb-1">{_t.translate?.corrections?.cell_usage_notes}</label>
<textarea <textarea
bind:value={editableUsageNotes} bind:value={editableUsageNotes}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
rows="2" rows="2"
placeholder={$t.translate?.corrections?.cell_usage_notes_placeholder} placeholder={_t.translate?.corrections?.cell_usage_notes_placeholder}
></textarea> ></textarea>
</div> </div>
@@ -309,14 +312,14 @@
onclick={cancelDictSubmit} onclick={cancelDictSubmit}
class="px-3 py-1.5 text-xs text-text bg-surface-muted rounded hover:bg-surface-muted transition-colors" class="px-3 py-1.5 text-xs text-text bg-surface-muted rounded hover:bg-surface-muted transition-colors"
> >
{$t.translate?.corrections?.cell_cancel} {_t.translate?.corrections?.cell_cancel}
</button> </button>
<button <button
onclick={handleDictSubmit} onclick={handleDictSubmit}
disabled={!selectedDictId} disabled={!selectedDictId}
class="px-3 py-1.5 text-xs text-white bg-warning rounded hover:bg-warning disabled:opacity-50 transition-colors" class="px-3 py-1.5 text-xs text-white bg-warning rounded hover:bg-warning disabled:opacity-50 transition-colors"
> >
{$t.translate?.corrections?.cell_submit} {_t.translate?.corrections?.cell_submit}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -14,7 +14,7 @@
<!-- @UX_RECOVERY Retry button on TranslationRunProgress for failed runs --> <!-- @UX_RECOVERY Retry button on TranslationRunProgress for failed runs -->
<!-- @UX_REACTIVITY Props -> $props() for config/state, callbacks for actions --> <!-- @UX_REACTIVITY Props -> $props() for config/state, callbacks for actions -->
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n/index.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js';
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
import { updateJob } from '$lib/api/translate.js'; import { updateJob } from '$lib/api/translate.js';
import HelpTooltip from '$lib/ui/HelpTooltip.svelte'; import HelpTooltip from '$lib/ui/HelpTooltip.svelte';
@@ -44,19 +44,23 @@
try { try {
await updateJob(jobId, { status: 'READY' }); await updateJob(jobId, { status: 'READY' });
status = 'READY'; status = 'READY';
addToast($t.translate?.config?.job_updated, 'success'); addToast(getT()?.translate?.config?.job_updated, 'success');
} catch (e) { } catch (e) {
addToast(e?.message || 'Failed to update status', 'error'); addToast(e?.message || 'Failed to update status', 'error');
} }
} }
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
</script> </script>
<section class="bg-surface-card border border-border rounded-lg p-6"> <section class="bg-surface-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold text-text mb-4">{$t.translate?.config?.run_translation}</h2> <h2 class="text-lg font-semibold text-text mb-4">{_t.translate?.config?.run_translation}</h2>
<!-- Status display + transition --> <!-- Status display + transition -->
<div class="flex items-center gap-3 mb-4 p-3 bg-surface-muted rounded-lg"> <div class="flex items-center gap-3 mb-4 p-3 bg-surface-muted rounded-lg">
<span class="text-sm text-text-muted">{$t.translate?.config?.status}:</span> <span class="text-sm text-text-muted">{_t.translate?.config?.status}:</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{status === 'READY' ? 'bg-success-light text-success' : ''} {status === 'READY' ? 'bg-success-light text-success' : ''}
{status === 'DRAFT' ? 'bg-warning-light text-warning' : ''} {status === 'DRAFT' ? 'bg-warning-light text-warning' : ''}
@@ -71,7 +75,7 @@
onclick={handleMarkReady} onclick={handleMarkReady}
class="ml-auto px-3 py-1 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors" class="ml-auto px-3 py-1 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors"
> >
{$t.translate?.config?.mark_ready || 'Mark as READY'} {_t.translate?.config?.mark_ready || 'Mark as READY'}
</button> </button>
{/if} {/if}
</div> </div>
@@ -89,16 +93,16 @@
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-text flex items-center gap-1"> <h3 class="text-sm font-semibold text-text flex items-center gap-1">
{$t.translate?.config?.run_incremental} {_t.translate?.config?.run_incremental}
<HelpTooltip text={$t.translate?.config?.help_incremental || ''} /> <HelpTooltip text={_t.translate?.config?.help_incremental || ''} />
</h3> </h3>
<p class="text-xs text-text-muted mt-1">{$t.translate?.config?.run_incremental_desc}</p> <p class="text-xs text-text-muted mt-1">{_t.translate?.config?.run_incremental_desc}</p>
<button <button
onclick={() => onTriggerRun(false)} onclick={() => onTriggerRun(false)}
disabled={isRunning || status === 'DRAFT'} disabled={isRunning || status === 'DRAFT'}
class="mt-3 px-5 py-1.5 text-sm font-medium bg-success text-white rounded-lg hover:bg-success disabled:opacity-50 disabled:cursor-not-allowed transition-colors" class="mt-3 px-5 py-1.5 text-sm font-medium bg-success text-white rounded-lg hover:bg-success disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
{isRunning && !isFullRun ? $t.translate?.config?.running : $t.translate?.config?.run_translation} {isRunning && !isFullRun ? _t.translate?.config?.running : _t.translate?.config?.run_translation}
</button> </button>
</div> </div>
</div> </div>
@@ -114,16 +118,16 @@
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-text flex items-center gap-1"> <h3 class="text-sm font-semibold text-text flex items-center gap-1">
{$t.translate?.config?.run_full} {_t.translate?.config?.run_full}
<HelpTooltip text={$t.translate?.config?.help_full || ''} /> <HelpTooltip text={_t.translate?.config?.help_full || ''} />
</h3> </h3>
<p class="text-xs text-text-muted mt-1">{$t.translate?.config?.run_full_desc}</p> <p class="text-xs text-text-muted mt-1">{_t.translate?.config?.run_full_desc}</p>
<button <button
onclick={() => onTriggerRun(true)} onclick={() => onTriggerRun(true)}
disabled={isRunning || status === 'DRAFT'} disabled={isRunning || status === 'DRAFT'}
class="mt-3 px-5 py-1.5 text-sm font-medium bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors" class="mt-3 px-5 py-1.5 text-sm font-medium bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
{isRunning && isFullRun ? $t.translate?.config?.running : $t.translate?.config?.full_translate} {isRunning && isFullRun ? _t.translate?.config?.running : _t.translate?.config?.full_translate}
</button> </button>
</div> </div>
</div> </div>
@@ -147,12 +151,12 @@
{#if completedRuns.length > 0} {#if completedRuns.length > 0}
<div class="mt-4"> <div class="mt-4">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-text">{$t.translate?.config?.recent_runs}</h3> <h3 class="text-sm font-medium text-text">{_t.translate?.config?.recent_runs}</h3>
<button <button
onclick={() => showPageBulkReplace = true} onclick={() => showPageBulkReplace = true}
class="px-3 py-1.5 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors" class="px-3 py-1.5 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors"
> >
{$t.translate?.run?.bulk_replace || 'Bulk Replace'} {_t.translate?.run?.bulk_replace || 'Bulk Replace'}
</button> </button>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
@@ -161,7 +165,7 @@
<div class="flex items-center justify-between gap-3 p-4"> <div class="flex items-center justify-between gap-3 p-4">
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-text-muted">{$t.translate?.run?.run_id}</span> <span class="text-xs text-text-muted">{_t.translate?.run?.run_id}</span>
<code class="text-xs text-text break-all">{run.id}</code> <code class="text-xs text-text break-all">{run.id}</code>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium <span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium
{run.status === 'COMPLETED' ? 'bg-success-light text-success' : ''} {run.status === 'COMPLETED' ? 'bg-success-light text-success' : ''}
@@ -173,10 +177,10 @@
</span> </span>
</div> </div>
<div class="mt-2 flex flex-wrap gap-3 text-xs text-text-muted"> <div class="mt-2 flex flex-wrap gap-3 text-xs text-text-muted">
<span>{$t.translate?.run?.total}: {run.total_records || 0}</span> <span>{_t.translate?.run?.total}: {run.total_records || 0}</span>
<span>{$t.translate?.run?.success}: {run.successful_records || 0}</span> <span>{_t.translate?.run?.success}: {run.successful_records || 0}</span>
<span>{$t.translate?.run?.failed}: {run.failed_records || 0}</span> <span>{_t.translate?.run?.failed}: {run.failed_records || 0}</span>
<span>{$t.translate?.run?.skipped}: {run.skipped_records || 0}</span> <span>{_t.translate?.run?.skipped}: {run.skipped_records || 0}</span>
</div> </div>
{#if run.error_message} {#if run.error_message}
<p class="mt-2 text-xs text-destructive break-words">{run.error_message}</p> <p class="mt-2 text-xs text-destructive break-words">{run.error_message}</p>
@@ -187,8 +191,8 @@
class="shrink-0 px-3 py-1.5 text-xs border border-border-strong text-text rounded hover:bg-surface-muted transition-colors" class="shrink-0 px-3 py-1.5 text-xs border border-border-strong text-text rounded hover:bg-surface-muted transition-colors"
> >
{expandedRunIds.includes(run.id) {expandedRunIds.includes(run.id)
? ($t.translate?.run?.hide_details || 'Hide details') ? (_t.translate?.run?.hide_details || 'Hide details')
: ($t.translate?.run?.show_details || 'Show details')} : (_t.translate?.run?.show_details || 'Show details')}
</button> </button>
</div> </div>
{#if expandedRunIds.includes(run.id)} {#if expandedRunIds.includes(run.id)}

View File

@@ -9,7 +9,7 @@
<!-- @UX_FEEDBACK Next-3-executions preview; toast on save/error --> <!-- @UX_FEEDBACK Next-3-executions preview; toast on save/error -->
<!-- @UX_RECOVERY Enable/disable toggle; delete schedule --> <!-- @UX_RECOVERY Enable/disable toggle; delete schedule -->
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n/index.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js';
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
import HelpTooltip from "$lib/ui/HelpTooltip.svelte"; import HelpTooltip from "$lib/ui/HelpTooltip.svelte";
import { import {
@@ -25,6 +25,9 @@
/** @type {'idle'|'editing'|'enabled'|'disabled'|'no_prior_run_warning'} */ /** @type {'idle'|'editing'|'enabled'|'disabled'|'no_prior_run_warning'} */
let uxState = $state('idle'); let uxState = $state('idle');
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
let cronExpression = $state('0 2 * * *'); let cronExpression = $state('0 2 * * *');
let timezone = $state('UTC'); let timezone = $state('UTC');
let executionMode = $state('full'); let executionMode = $state('full');
@@ -80,25 +83,25 @@
execution_mode: executionMode, execution_mode: executionMode,
}; };
await setSchedule(jobId, payload); await setSchedule(jobId, payload);
addToast($t.translate?.schedule?.saved || 'Schedule saved', 'success'); addToast(getT()?.translate?.schedule?.saved || 'Schedule saved', 'success');
scheduleExists = true; scheduleExists = true;
uxState = isEnabled ? 'enabled' : 'disabled'; uxState = isEnabled ? 'enabled' : 'disabled';
loadNextExecutions(); loadNextExecutions();
} catch (err) { } catch (err) {
addToast(err?.message || ($t.translate?.schedule?.save_failed || 'Failed to save schedule'), 'error'); addToast(err?.message || (getT()?.translate?.schedule?.save_failed || 'Failed to save schedule'), 'error');
} }
} }
async function handleDelete() { async function handleDelete() {
try { try {
await deleteSchedule(jobId); await deleteSchedule(jobId);
addToast($t.translate?.schedule?.deleted || 'Schedule deleted', 'success'); addToast(getT()?.translate?.schedule?.deleted || 'Schedule deleted', 'success');
scheduleExists = false; scheduleExists = false;
uxState = 'idle'; uxState = 'idle';
cronExpression = '0 2 * * *'; cronExpression = '0 2 * * *';
nextExecutions = []; nextExecutions = [];
} catch (err) { } catch (err) {
addToast(err?.message || ($t.translate?.schedule?.delete_failed || 'Failed to delete schedule'), 'error'); addToast(err?.message || (getT()?.translate?.schedule?.delete_failed || 'Failed to delete schedule'), 'error');
} }
} }
@@ -108,7 +111,7 @@
await disableSchedule(jobId); await disableSchedule(jobId);
isEnabled = false; isEnabled = false;
uxState = 'disabled'; uxState = 'disabled';
addToast($t.translate?.schedule?.disabled_toast || 'Schedule disabled', 'info'); addToast(getT()?.translate?.schedule?.disabled_toast || 'Schedule disabled', 'info');
} else { } else {
if (!scheduleExists) { if (!scheduleExists) {
await handleSave(); await handleSave();
@@ -117,11 +120,11 @@
await enableSchedule(jobId); await enableSchedule(jobId);
isEnabled = true; isEnabled = true;
uxState = 'enabled'; uxState = 'enabled';
addToast($t.translate?.schedule?.enabled_toast || 'Schedule enabled', 'success'); addToast(getT()?.translate?.schedule?.enabled_toast || 'Schedule enabled', 'success');
loadNextExecutions(); loadNextExecutions();
} }
} catch (err) { } catch (err) {
addToast(err?.message || ($t.translate?.schedule?.toggle_failed || 'Failed to toggle schedule'), 'error'); addToast(err?.message || (getT()?.translate?.schedule?.toggle_failed || 'Failed to toggle schedule'), 'error');
} }
} }
@@ -132,11 +135,11 @@
const hour = parts[0] === '*' ? '' : parts[0]; const hour = parts[0] === '*' ? '' : parts[0];
const min = parts[1]; const min = parts[1];
if (parts[0] === '*') { if (parts[0] === '*') {
return ($t.translate?.schedule?.description_every_hour || 'at :{minute} past the hour').replace('{minute}', min); return (getT()?.translate?.schedule?.description_every_hour || 'at :{minute} past the hour').replace('{minute}', min);
} }
return ($t.translate?.schedule?.description_daily || 'Daily at {hour}:{minute}').replace('{hour}', hour).replace('{minute}', min); return (getT()?.translate?.schedule?.description_daily || 'Daily at {hour}:{minute}').replace('{hour}', hour).replace('{minute}', min);
} }
return ($t.translate?.schedule?.description_cron || 'Cron: {cron}').replace('{cron}', cron); return (getT()?.translate?.schedule?.description_cron || 'Cron: {cron}').replace('{cron}', cron);
} }
const commonTimezones = [ const commonTimezones = [
@@ -148,14 +151,14 @@
<div class="bg-surface-card border border-border rounded-lg p-4 space-y-4"> <div class="bg-surface-card border border-border rounded-lg p-4 space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-text">{$t.translate?.schedule?.title || 'Schedule Configuration'}</h3> <h3 class="text-lg font-semibold text-text">{_t.translate?.schedule?.title || 'Schedule Configuration'}</h3>
{#if !isLoading && scheduleExists} {#if !isLoading && scheduleExists}
<button <button
onclick={handleToggle} onclick={handleToggle}
class="inline-flex items-center px-3 py-1.5 text-sm rounded-full {isEnabled ? 'bg-success-light text-success' : 'bg-surface-muted text-text-muted'}" class="inline-flex items-center px-3 py-1.5 text-sm rounded-full {isEnabled ? 'bg-success-light text-success' : 'bg-surface-muted text-text-muted'}"
> >
<span class="w-2 h-2 rounded-full mr-2 {isEnabled ? 'bg-success' : 'bg-border-strong'}"></span> <span class="w-2 h-2 rounded-full mr-2 {isEnabled ? 'bg-success' : 'bg-border-strong'}"></span>
{isEnabled ? ($t.translate?.schedule?.enabled || 'Enabled') : ($t.translate?.schedule?.disabled || 'Disabled')} {isEnabled ? (_t.translate?.schedule?.enabled || 'Enabled') : (_t.translate?.schedule?.disabled || 'Disabled')}
</button> </button>
{/if} {/if}
</div> </div>
@@ -168,10 +171,10 @@
<svg class="w-12 h-12 text-text-subtle mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-12 h-12 text-text-subtle mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<p class="text-sm text-text-muted mb-1">{$t.translate?.schedule?.no_schedule || 'No schedule configured'}</p> <p class="text-sm text-text-muted mb-1">{_t.translate?.schedule?.no_schedule || 'No schedule configured'}</p>
<p class="text-xs text-text-subtle mb-4">{$t.translate?.schedule?.no_schedule_hint || 'Set up a recurring schedule for automatic translations'}</p> <p class="text-xs text-text-subtle mb-4">{_t.translate?.schedule?.no_schedule_hint || 'Set up a recurring schedule for automatic translations'}</p>
<button onclick={() => (uxState = 'editing')} class="px-4 py-2 text-sm text-white bg-primary rounded-lg hover:bg-primary-hover"> <button onclick={() => (uxState = 'editing')} class="px-4 py-2 text-sm text-white bg-primary rounded-lg hover:bg-primary-hover">
{$t.translate?.schedule?.create_schedule || 'Create Schedule'} {_t.translate?.schedule?.create_schedule || 'Create Schedule'}
</button> </button>
</div> </div>
@@ -180,8 +183,8 @@
<!-- Cron expression --> <!-- Cron expression -->
<div> <div>
<span class="flex items-center gap-1.5 mb-1"> <span class="flex items-center gap-1.5 mb-1">
<label class="text-sm font-medium text-text">{$t.translate?.schedule?.cron_expression || 'Cron Expression'}</label> <label class="text-sm font-medium text-text">{_t.translate?.schedule?.cron_expression || 'Cron Expression'}</label>
<HelpTooltip text={$t.translate?.schedule?.help_cron || ""} /> <HelpTooltip text={_t.translate?.schedule?.help_cron || ""} />
</span> </span>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
@@ -196,11 +199,11 @@
disabled={!isEditing} disabled={!isEditing}
class="px-3 py-2 border border-border-strong rounded-lg text-sm disabled:bg-surface-muted disabled:text-text-muted" class="px-3 py-2 border border-border-strong rounded-lg text-sm disabled:bg-surface-muted disabled:text-text-muted"
> >
<option value="0 2 * * *">{$t.translate?.schedule?.preset_daily || 'Daily at 02:00'}</option> <option value="0 2 * * *">{_t.translate?.schedule?.preset_daily || 'Daily at 02:00'}</option>
<option value="0 */6 * * *">{$t.translate?.schedule?.preset_every_6h || 'Every 6 hours'}</option> <option value="0 */6 * * *">{_t.translate?.schedule?.preset_every_6h || 'Every 6 hours'}</option>
<option value="30 1 * * 0">{$t.translate?.schedule?.preset_weekly || 'Weekly (Sun 01:30)'}</option> <option value="30 1 * * 0">{_t.translate?.schedule?.preset_weekly || 'Weekly (Sun 01:30)'}</option>
<option value="0 3 1 * *">{$t.translate?.schedule?.preset_monthly || 'Monthly (1st at 03:00)'}</option> <option value="0 3 1 * *">{_t.translate?.schedule?.preset_monthly || 'Monthly (1st at 03:00)'}</option>
<option value="*/15 * * * *">{$t.translate?.schedule?.preset_every_15min || 'Every 15 min'}</option> <option value="*/15 * * * *">{_t.translate?.schedule?.preset_every_15min || 'Every 15 min'}</option>
</select> </select>
</div> </div>
<p class="text-xs text-text-subtle mt-1"> <p class="text-xs text-text-subtle mt-1">
@@ -211,8 +214,8 @@
<!-- Timezone --> <!-- Timezone -->
<div> <div>
<span class="flex items-center gap-1.5 mb-1"> <span class="flex items-center gap-1.5 mb-1">
<label class="text-sm font-medium text-text">{$t.translate?.schedule?.timezone || 'Timezone'}</label> <label class="text-sm font-medium text-text">{_t.translate?.schedule?.timezone || 'Timezone'}</label>
<HelpTooltip text={$t.translate?.schedule?.help_timezone || ""} /> <HelpTooltip text={_t.translate?.schedule?.help_timezone || ""} />
</span> </span>
<select <select
bind:value={timezone} bind:value={timezone}
@@ -228,22 +231,22 @@
<!-- Execution mode --> <!-- Execution mode -->
<div> <div>
<span class="flex items-center gap-1.5 mb-1"> <span class="flex items-center gap-1.5 mb-1">
<label class="text-sm font-medium text-text">{$t.translate?.schedule?.execution_mode || 'Execution Mode'}</label> <label class="text-sm font-medium text-text">{_t.translate?.schedule?.execution_mode || 'Execution Mode'}</label>
<HelpTooltip text={$t.translate?.schedule?.help_execution_mode || ""} /> <HelpTooltip text={_t.translate?.schedule?.help_execution_mode || ""} />
</span> </span>
<div class="flex gap-4"> <div class="flex gap-4">
<label class="inline-flex items-center cursor-pointer"> <label class="inline-flex items-center cursor-pointer">
<input type="radio" bind:group={executionMode} value="full" class="sr-only peer" disabled={!isEditing} /> <input type="radio" bind:group={executionMode} value="full" class="sr-only peer" disabled={!isEditing} />
<div class="px-4 py-2 text-sm border border-border-strong rounded-lg peer-checked:border-primary-ring peer-checked:bg-primary-light peer-checked:text-primary hover:border-border-strong {isEditing ? '' : 'opacity-70'}"> <div class="px-4 py-2 text-sm border border-border-strong rounded-lg peer-checked:border-primary-ring peer-checked:bg-primary-light peer-checked:text-primary hover:border-border-strong {isEditing ? '' : 'opacity-70'}">
<div class="font-medium">{$t.translate?.schedule?.mode_full || 'Full'}</div> <div class="font-medium">{_t.translate?.schedule?.mode_full || 'Full'}</div>
<div class="text-xs text-text-muted mt-0.5">{$t.translate?.schedule?.mode_full_desc || 'Translate all rows every run'}</div> <div class="text-xs text-text-muted mt-0.5">{_t.translate?.schedule?.mode_full_desc || 'Translate all rows every run'}</div>
</div> </div>
</label> </label>
<label class="inline-flex items-center cursor-pointer"> <label class="inline-flex items-center cursor-pointer">
<input type="radio" bind:group={executionMode} value="new_key_only" class="sr-only peer" disabled={!isEditing} /> <input type="radio" bind:group={executionMode} value="new_key_only" class="sr-only peer" disabled={!isEditing} />
<div class="px-4 py-2 text-sm border border-border-strong rounded-lg peer-checked:border-primary-ring peer-checked:bg-primary-light peer-checked:text-primary hover:border-border-strong {isEditing ? '' : 'opacity-70'}"> <div class="px-4 py-2 text-sm border border-border-strong rounded-lg peer-checked:border-primary-ring peer-checked:bg-primary-light peer-checked:text-primary hover:border-border-strong {isEditing ? '' : 'opacity-70'}">
<div class="font-medium">{$t.translate?.schedule?.mode_new_keys || 'New Keys Only'}</div> <div class="font-medium">{_t.translate?.schedule?.mode_new_keys || 'New Keys Only'}</div>
<div class="text-xs text-text-muted mt-0.5">{$t.translate?.schedule?.mode_new_keys_desc || 'Only translate rows with new key values'}</div> <div class="text-xs text-text-muted mt-0.5">{_t.translate?.schedule?.mode_new_keys_desc || 'Only translate rows with new key values'}</div>
</div> </div>
</label> </label>
</div> </div>
@@ -252,7 +255,7 @@
<!-- Next executions preview --> <!-- Next executions preview -->
{#if nextExecutions.length > 0} {#if nextExecutions.length > 0}
<div class="bg-surface-muted rounded-lg p-3"> <div class="bg-surface-muted rounded-lg p-3">
<p class="text-xs font-medium text-text-muted mb-2">{$t.translate?.schedule?.next_executions || 'Next Executions'}</p> <p class="text-xs font-medium text-text-muted mb-2">{_t.translate?.schedule?.next_executions || 'Next Executions'}</p>
{#each nextExecutions as dt, i} {#each nextExecutions as dt, i}
<p class="text-xs text-text-muted font-mono">{i + 1}. {new Date(dt).toLocaleString()}</p> <p class="text-xs text-text-muted font-mono">{i + 1}. {new Date(dt).toLocaleString()}</p>
{/each} {/each}
@@ -263,7 +266,7 @@
{#if uxState === 'no_prior_run_warning'} {#if uxState === 'no_prior_run_warning'}
<div class="bg-warning-light border border-warning rounded-lg p-3"> <div class="bg-warning-light border border-warning rounded-lg p-3">
<p class="text-xs text-warning"> <p class="text-xs text-warning">
{$t.translate?.schedule?.no_prior_run_warning || 'No prior successful manual run. Schedule may run with default settings. Consider running manually first.'} {_t.translate?.schedule?.no_prior_run_warning || 'No prior successful manual run. Schedule may run with default settings. Consider running manually first.'}
</p> </p>
</div> </div>
{/if} {/if}
@@ -272,7 +275,7 @@
<div class="flex justify-between items-center pt-2"> <div class="flex justify-between items-center pt-2">
{#if scheduleExists} {#if scheduleExists}
<button onclick={handleDelete} class="px-3 py-1.5 text-sm text-destructive hover:text-destructive"> <button onclick={handleDelete} class="px-3 py-1.5 text-sm text-destructive hover:text-destructive">
{$t.translate?.schedule?.delete_schedule || 'Delete Schedule'} {_t.translate?.schedule?.delete_schedule || 'Delete Schedule'}
</button> </button>
{:else} {:else}
<div></div> <div></div>
@@ -280,12 +283,12 @@
<div class="flex gap-2"> <div class="flex gap-2">
{#if scheduleExists && (uxState === 'enabled' || uxState === 'disabled')} {#if scheduleExists && (uxState === 'enabled' || uxState === 'disabled')}
<button onclick={() => (uxState = 'editing')} class="px-4 py-2 text-sm text-text bg-surface-muted rounded-lg hover:bg-surface-muted"> <button onclick={() => (uxState = 'editing')} class="px-4 py-2 text-sm text-text bg-surface-muted rounded-lg hover:bg-surface-muted">
{$t.translate?.schedule?.edit || 'Edit'} {_t.translate?.schedule?.edit || 'Edit'}
</button> </button>
{/if} {/if}
{#if uxState === 'editing'} {#if uxState === 'editing'}
<button onclick={handleSave} class="px-4 py-2 text-sm text-white bg-primary rounded-lg hover:bg-primary-hover"> <button onclick={handleSave} class="px-4 py-2 text-sm text-white bg-primary rounded-lg hover:bg-primary-hover">
{scheduleExists ? ($t.translate?.schedule?.update || 'Update') : ($t.translate?.schedule?.create || 'Create')} {scheduleExists ? (_t.translate?.schedule?.update || 'Update') : (_t.translate?.schedule?.create || 'Create')}
</button> </button>
{/if} {/if}
</div> </div>

View File

@@ -13,7 +13,7 @@
<!-- @UX_REACTIVITY Props -> $bindable() for all target fields --> <!-- @UX_REACTIVITY Props -> $bindable() for all target fields -->
<!-- @UX_REACTIVITY Props -> $props() for databases array --> <!-- @UX_REACTIVITY Props -> $props() for databases array -->
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n/index.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js';
import HelpTooltip from '$lib/ui/HelpTooltip.svelte'; import HelpTooltip from '$lib/ui/HelpTooltip.svelte';
import TargetSchemaHint from './TargetSchemaHint.svelte'; import TargetSchemaHint from './TargetSchemaHint.svelte';
@@ -34,46 +34,50 @@
databases = [], databases = [],
databasesLoading = false, databasesLoading = false,
} = $props(); } = $props();
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
</script> </script>
<section class="bg-surface-card border border-border rounded-lg p-6"> <section class="bg-surface-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold text-text mb-4">{$t.translate?.config?.target_table_title}</h2> <h2 class="text-lg font-semibold text-text mb-4">{_t.translate?.config?.target_table_title}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.target_schema} {_t.translate?.config?.target_schema}
<HelpTooltip text={$t.translate?.config?.help_target_schema || ''} /> <HelpTooltip text={_t.translate?.config?.help_target_schema || ''} />
</label> </label>
<input <input
type="text" type="text"
bind:value={targetSchema} bind:value={targetSchema}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder={$t.translate?.config?.target_schema_placeholder} placeholder={_t.translate?.config?.target_schema_placeholder}
/> />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.target_table} {_t.translate?.config?.target_table}
<HelpTooltip text={$t.translate?.config?.help_target_table || ''} /> <HelpTooltip text={_t.translate?.config?.help_target_table || ''} />
</label> </label>
<input <input
type="text" type="text"
bind:value={targetTable} bind:value={targetTable}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder={$t.translate?.config?.target_table_placeholder} placeholder={_t.translate?.config?.target_table_placeholder}
/> />
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.target_database} {_t.translate?.config?.target_database}
<HelpTooltip text={$t.translate?.config?.help_target_database || ''} /> <HelpTooltip text={_t.translate?.config?.help_target_database || ''} />
</label> </label>
<select <select
bind:value={targetDatabaseId} bind:value={targetDatabaseId}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
disabled={!environmentId || databasesLoading} disabled={!environmentId || databasesLoading}
> >
<option value="">{$t.translate?.config?.select_target_database}</option> <option value="">{_t.translate?.config?.select_target_database}</option>
{#each databases as db} {#each databases as db}
<option value={String(db.id || db.uuid)}> <option value={String(db.id || db.uuid)}>
{db.database_name || db.name} ({db.engine || db.backend || '?'}) {db.database_name || db.name} ({db.engine || db.backend || '?'})
@@ -81,11 +85,11 @@
{/each} {/each}
</select> </select>
{#if databasesLoading} {#if databasesLoading}
<p class="text-xs text-text-subtle mt-1">{$t.translate?.config?.loading_databases}</p> <p class="text-xs text-text-subtle mt-1">{_t.translate?.config?.loading_databases}</p>
{:else if !environmentId} {:else if !environmentId}
<p class="text-xs text-text-subtle mt-1">{$t.translate?.config?.select_environment}</p> <p class="text-xs text-text-subtle mt-1">{_t.translate?.config?.select_environment}</p>
{:else} {:else}
<p class="text-xs text-text-subtle mt-1">{$t.translate?.config?.target_database_hint}</p> <p class="text-xs text-text-subtle mt-1">{_t.translate?.config?.target_database_hint}</p>
{/if} {/if}
</div> </div>
</div> </div>
@@ -93,14 +97,14 @@
<!-- Target Column Mapping --> <!-- Target Column Mapping -->
<div class="mt-4 pt-4 border-t border-border"> <div class="mt-4 pt-4 border-t border-border">
<h3 class="text-sm font-semibold text-text mb-3 flex items-center gap-2"> <h3 class="text-sm font-semibold text-text mb-3 flex items-center gap-2">
{$t.translate?.config?.target_column_mapping_title || 'Target Column Mapping'} {_t.translate?.config?.target_column_mapping_title || 'Target Column Mapping'}
<HelpTooltip text={$t.translate?.config?.target_column_mapping_description || ''} /> <HelpTooltip text={_t.translate?.config?.target_column_mapping_description || ''} />
</h3> </h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.target_column_label} {_t.translate?.config?.target_column_label}
<HelpTooltip text={$t.translate?.config?.help_target_column || ''} /> <HelpTooltip text={_t.translate?.config?.help_target_column || ''} />
</label> </label>
<input <input
type="text" type="text"
@@ -108,12 +112,12 @@
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder="e.g. translated_text" placeholder="e.g. translated_text"
/> />
<p class="text-xs text-text-subtle mt-1">{$t.translate?.config?.target_column_hint}</p> <p class="text-xs text-text-subtle mt-1">{_t.translate?.config?.target_column_hint}</p>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.target_language_column_label} {_t.translate?.config?.target_language_column_label}
<HelpTooltip text={$t.translate?.config?.help_target_language_column || ''} /> <HelpTooltip text={_t.translate?.config?.help_target_language_column || ''} />
</label> </label>
<input <input
type="text" type="text"
@@ -121,12 +125,12 @@
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder="e.g. lang_code" placeholder="e.g. lang_code"
/> />
<p class="text-xs text-text-subtle mt-1">{$t.translate?.config?.target_language_column_hint}</p> <p class="text-xs text-text-subtle mt-1">{_t.translate?.config?.target_language_column_hint}</p>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.target_source_column_label} {_t.translate?.config?.target_source_column_label}
<HelpTooltip text={$t.translate?.config?.help_target_source_column || ''} /> <HelpTooltip text={_t.translate?.config?.help_target_source_column || ''} />
</label> </label>
<input <input
type="text" type="text"
@@ -134,12 +138,12 @@
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder="e.g. source_text" placeholder="e.g. source_text"
/> />
<p class="text-xs text-text-subtle mt-1">{$t.translate?.config?.target_source_column_hint}</p> <p class="text-xs text-text-subtle mt-1">{_t.translate?.config?.target_source_column_hint}</p>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.target_source_language_column_label} {_t.translate?.config?.target_source_language_column_label}
<HelpTooltip text={$t.translate?.config?.help_target_source_language_column || ''} /> <HelpTooltip text={_t.translate?.config?.help_target_source_language_column || ''} />
</label> </label>
<input <input
type="text" type="text"
@@ -147,7 +151,7 @@
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder="e.g. src_lang" placeholder="e.g. src_lang"
/> />
<p class="text-xs text-text-subtle mt-1">{$t.translate?.config?.target_source_language_column_hint}</p> <p class="text-xs text-text-subtle mt-1">{_t.translate?.config?.target_source_language_column_hint}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -155,13 +159,13 @@
<!-- Write Settings: batch size, upsert strategy, source reference --> <!-- Write Settings: batch size, upsert strategy, source reference -->
<div class="mt-4 pt-4 border-t border-border"> <div class="mt-4 pt-4 border-t border-border">
<h3 class="text-sm font-semibold text-text mb-3"> <h3 class="text-sm font-semibold text-text mb-3">
{$t.translate?.config?.write_settings || 'Write Settings'} {_t.translate?.config?.write_settings || 'Write Settings'}
</h3> </h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.batch_size} {_t.translate?.config?.batch_size}
<HelpTooltip text={$t.translate?.config?.help_batch_size || ''} /> <HelpTooltip text={_t.translate?.config?.help_batch_size || ''} />
</label> </label>
<input <input
type="number" type="number"
@@ -173,13 +177,13 @@
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1"> <label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{$t.translate?.config?.upsert_strategy} {_t.translate?.config?.upsert_strategy}
<HelpTooltip text={$t.translate?.config?.help_upsert_strategy || ''} /> <HelpTooltip text={_t.translate?.config?.help_upsert_strategy || ''} />
</label> </label>
<select bind:value={upsertStrategy} class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"> <select bind:value={upsertStrategy} class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm">
<option value="MERGE">{$t.translate?.config?.upsert_merge}</option> <option value="MERGE">{_t.translate?.config?.upsert_merge}</option>
<option value="INSERT">{$t.translate?.config?.upsert_insert}</option> <option value="INSERT">{_t.translate?.config?.upsert_insert}</option>
<option value="UPDATE">{$t.translate?.config?.upsert_update}</option> <option value="UPDATE">{_t.translate?.config?.upsert_update}</option>
</select> </select>
</div> </div>
</div> </div>
@@ -192,11 +196,11 @@
/> />
<div> <div>
<span class="text-sm font-medium text-text flex items-center gap-1"> <span class="text-sm font-medium text-text flex items-center gap-1">
{$t.translate?.config?.include_source_reference || 'Include source language in translations'} {_t.translate?.config?.include_source_reference || 'Include source language in translations'}
<HelpTooltip text={$t.translate?.config?.help_include_source_reference || ''} /> <HelpTooltip text={_t.translate?.config?.help_include_source_reference || ''} />
</span> </span>
<p class="text-xs text-text-subtle mt-0.5"> <p class="text-xs text-text-subtle mt-0.5">
{$t.translate?.config?.include_source_reference_hint || 'The original text will be stored as a verified reference copy in its detected language'} {_t.translate?.config?.include_source_reference_hint || 'The original text will be stored as a verified reference copy in its detected language'}
</p> </p>
</div> </div>
</label> </label>

View File

@@ -13,7 +13,7 @@
<script lang="ts"> <script lang="ts">
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
import { submitCorrection, dictionaryApi } from '$lib/api/translate.js'; import { submitCorrection, dictionaryApi } from '$lib/api/translate.js';
import { t, _ } from '$lib/i18n/index.svelte.js'; import { _, getT } from '$lib/i18n/index.svelte.js';
let { let {
sourceTerm = '', sourceTerm = '',
@@ -32,6 +32,9 @@
/** @type {'closed'|'selecting'|'editing'|'submitting'|'conflict_detected'|'submitted'} */ /** @type {'closed'|'selecting'|'editing'|'submitting'|'conflict_detected'|'submitted'} */
let uxState = $state('closed'); let uxState = $state('closed');
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
let correctedTarget = $state(''); let correctedTarget = $state('');
let dictionaries = $state([]); let dictionaries = $state([]);
let selectedDictId = $state(''); let selectedDictId = $state('');
@@ -147,7 +150,7 @@
<div class="bg-surface-card rounded-lg shadow-xl p-6 w-full max-w-md mx-4" onclick={(e) => e.stopPropagation()}> <div class="bg-surface-card rounded-lg shadow-xl p-6 w-full max-w-md mx-4" onclick={(e) => e.stopPropagation()}>
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-text">{$t.translate?.term_correction?.title}</h3> <h3 class="text-lg font-semibold text-text">{_t.translate?.term_correction?.title}</h3>
<button onclick={handleClose} class="text-text-subtle hover:text-text-muted"> <button onclick={handleClose} class="text-text-subtle hover:text-text-muted">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" 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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
@@ -157,14 +160,14 @@
<!-- Selecting state --> <!-- Selecting state -->
{#if uxState === 'selecting'} {#if uxState === 'selecting'}
<p class="text-sm text-text-muted">{$t.translate?.term_correction?.loading_dictionaries}</p> <p class="text-sm text-text-muted">{_t.translate?.term_correction?.loading_dictionaries}</p>
<!-- Editing state --> <!-- Editing state -->
{:else if uxState === 'editing'} {:else if uxState === 'editing'}
<div class="space-y-4"> <div class="space-y-4">
{#if sourceLanguage || targetLanguage} {#if sourceLanguage || targetLanguage}
<div class="bg-primary-light border-primary-ring rounded-lg px-3 py-2 text-xs text-primary"> <div class="bg-primary-light border-primary-ring rounded-lg px-3 py-2 text-xs text-primary">
<span class="font-medium">{$t.translate?.term_correction?.language_pair}</span> {sourceLanguage || $t.translate?.term_correction?.detected}{targetLanguage || languageCode} <span class="font-medium">{_t.translate?.term_correction?.language_pair}</span> {sourceLanguage || _t.translate?.term_correction?.detected}{targetLanguage || languageCode}
</div> </div>
{/if} {/if}
@@ -176,13 +179,13 @@
> >
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<span>📋</span> <span>📋</span>
<span>{$t.translate?.term_correction?.context_from_source}</span> <span>{_t.translate?.term_correction?.context_from_source}</span>
{#if contextData} {#if contextData}
<span class="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full" <span class="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full"
class:bg-primary-light:text-primary={!hasEditedContext} class:bg-primary-light:text-primary={!hasEditedContext}
class:bg-success-light:text-success={hasEditedContext} class:bg-success-light:text-success={hasEditedContext}
> >
{hasEditedContext ? '✏️ ' + $t.translate?.term_correction?.edited_badge : '🤖 ' + $t.translate?.term_correction?.auto_badge} {hasEditedContext ? '✏️ ' + _t.translate?.term_correction?.edited_badge : '🤖 ' + _t.translate?.term_correction?.auto_badge}
</span> </span>
{/if} {/if}
</span> </span>
@@ -204,7 +207,7 @@
}} }}
class="text-[11px] px-2 py-1 rounded border border-border-strong text-text-muted hover:bg-surface-muted" class="text-[11px] px-2 py-1 rounded border border-border-strong text-text-muted hover:bg-surface-muted"
> >
{$t.translate?.term_correction?.edit_context} {_t.translate?.term_correction?.edit_context}
</button> </button>
<button <button
onclick={() => { onclick={() => {
@@ -213,11 +216,11 @@
}} }}
class="text-[11px] px-2 py-1 rounded border border-destructive-light text-destructive hover:bg-destructive-light" class="text-[11px] px-2 py-1 rounded border border-destructive-light text-destructive hover:bg-destructive-light"
> >
{$t.translate?.term_correction?.remove_context} {_t.translate?.term_correction?.remove_context}
</button> </button>
</div> </div>
{:else} {:else}
<p class="text-[11px] text-text-subtle italic">{$t.translate?.term_correction?.no_context}</p> <p class="text-[11px] text-text-subtle italic">{_t.translate?.term_correction?.no_context}</p>
<button <button
onclick={() => { onclick={() => {
contextData = JSON.parse(JSON.stringify(initialContextData)); contextData = JSON.parse(JSON.stringify(initialContextData));
@@ -226,7 +229,7 @@
}} }}
class="text-[11px] px-2 py-1 rounded border border-border-strong text-text-muted hover:bg-surface-muted" class="text-[11px] px-2 py-1 rounded border border-border-strong text-text-muted hover:bg-surface-muted"
> >
{$t.translate?.term_correction?.restore_context} {_t.translate?.term_correction?.restore_context}
</button> </button>
{/if} {/if}
</div> </div>
@@ -236,48 +239,48 @@
<!-- Usage notes --> <!-- Usage notes -->
<div> <div>
<label class="block text-xs font-medium text-text-muted mb-1"> <label class="block text-xs font-medium text-text-muted mb-1">
📝 {$t.translate?.term_correction?.usage_notes} 📝 {_t.translate?.term_correction?.usage_notes}
</label> </label>
<textarea <textarea
bind:value={contextUsageNotes} bind:value={contextUsageNotes}
placeholder={$t.translate?.term_correction?.usage_notes_placeholder} placeholder={_t.translate?.term_correction?.usage_notes_placeholder}
rows="2" rows="2"
class="w-full px-3 py-2 border border-border-strong rounded-lg text-xs focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring resize-none" class="w-full px-3 py-2 border border-border-strong rounded-lg text-xs focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring resize-none"
></textarea> ></textarea>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-text mb-1">{$t.translate?.term_correction?.source_term}</label> <label class="block text-sm font-medium text-text mb-1">{_t.translate?.term_correction?.source_term}</label>
<input type="text" readonly value={sourceTerm} class="w-full px-3 py-2 bg-surface-muted border border-border rounded-lg text-sm" /> <input type="text" readonly value={sourceTerm} class="w-full px-3 py-2 bg-surface-muted border border-border rounded-lg text-sm" />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-text mb-1">{$t.translate?.term_correction?.incorrect_target}</label> <label class="block text-sm font-medium text-text mb-1">{_t.translate?.term_correction?.incorrect_target}</label>
<input type="text" readonly value={incorrectTarget} class="w-full px-3 py-2 bg-surface-muted border border-border rounded-lg text-sm" /> <input type="text" readonly value={incorrectTarget} class="w-full px-3 py-2 bg-surface-muted border border-border rounded-lg text-sm" />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-text mb-1">{$t.translate?.term_correction?.source_language}</label> <label class="block text-sm font-medium text-text mb-1">{_t.translate?.term_correction?.source_language}</label>
<input type="text" readonly value={sourceLanguage || $t.translate?.term_correction?.auto_detected} class="w-full px-3 py-2 bg-surface-muted border border-border rounded-lg text-sm font-mono" /> <input type="text" readonly value={sourceLanguage || _t.translate?.term_correction?.auto_detected} class="w-full px-3 py-2 bg-surface-muted border border-border rounded-lg text-sm font-mono" />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-text mb-1">{$t.translate?.term_correction?.target_language}</label> <label class="block text-sm font-medium text-text mb-1">{_t.translate?.term_correction?.target_language}</label>
<input type="text" readonly value={targetLanguage || '—'} class="w-full px-3 py-2 bg-surface-muted border border-border rounded-lg text-sm font-mono" /> <input type="text" readonly value={targetLanguage || '—'} class="w-full px-3 py-2 bg-surface-muted border border-border rounded-lg text-sm font-mono" />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-text mb-1">{$t.translate?.term_correction?.corrected_target}</label> <label class="block text-sm font-medium text-text mb-1">{_t.translate?.term_correction?.corrected_target}</label>
<input <input
type="text" type="text"
bind:value={correctedTarget} bind:value={correctedTarget}
placeholder={$t.translate?.term_correction?.corrected_placeholder} placeholder={_t.translate?.term_correction?.corrected_placeholder}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
/> />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-text mb-1">{$t.translate?.term_correction?.dictionary}</label> <label class="block text-sm font-medium text-text mb-1">{_t.translate?.term_correction?.dictionary}</label>
<select <select
bind:value={selectedDictId} bind:value={selectedDictId}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm" class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
> >
<option value="">{$t.translate?.term_correction?.select_dictionary}</option> <option value="">{_t.translate?.term_correction?.select_dictionary}</option>
{#each dictionaries as dict} {#each dictionaries as dict}
<option value={dict.id}>{dict.name}</option> <option value={dict.id}>{dict.name}</option>
{/each} {/each}
@@ -285,14 +288,14 @@
</div> </div>
<div class="flex justify-end gap-2 pt-2"> <div class="flex justify-end gap-2 pt-2">
<button onclick={handleClose} class="px-4 py-2 text-sm text-text bg-surface-muted rounded-lg hover:bg-surface-muted"> <button onclick={handleClose} class="px-4 py-2 text-sm text-text bg-surface-muted rounded-lg hover:bg-surface-muted">
{$t.translate?.term_correction?.cancel} {_t.translate?.term_correction?.cancel}
</button> </button>
<button <button
onclick={handleSubmit} onclick={handleSubmit}
disabled={!selectedDictId || !correctedTarget.trim()} disabled={!selectedDictId || !correctedTarget.trim()}
class="px-4 py-2 text-sm text-white bg-primary rounded-lg hover:bg-primary-hover disabled:opacity-50" class="px-4 py-2 text-sm text-white bg-primary rounded-lg hover:bg-primary-hover disabled:opacity-50"
> >
{$t.translate?.term_correction?.submit_correction} {_t.translate?.term_correction?.submit_correction}
</button> </button>
</div> </div>
</div> </div>
@@ -301,24 +304,24 @@
{:else if uxState === 'submitting'} {:else if uxState === 'submitting'}
<div class="text-center py-6"> <div class="text-center py-6">
<div class="animate-spin w-8 h-8 border-2 border-primary-ring border-t-transparent rounded-full mx-auto mb-3"></div> <div class="animate-spin w-8 h-8 border-2 border-primary-ring border-t-transparent rounded-full mx-auto mb-3"></div>
<p class="text-sm text-text-muted">{$t.translate?.term_correction?.submitting}</p> <p class="text-sm text-text-muted">{_t.translate?.term_correction?.submitting}</p>
</div> </div>
<!-- Conflict state --> <!-- Conflict state -->
{:else if uxState === 'conflict_detected'} {:else if uxState === 'conflict_detected'}
<div class="space-y-4"> <div class="space-y-4">
<div class="bg-warning-light border border-warning rounded-lg p-4"> <div class="bg-warning-light border border-warning rounded-lg p-4">
<p class="text-sm font-medium text-warning">{$t.translate?.term_correction?.conflict_detected}</p> <p class="text-sm font-medium text-warning">{_t.translate?.term_correction?.conflict_detected}</p>
<p class="text-xs text-warning mt-1"> <p class="text-xs text-warning mt-1">
{$t.translate?.term_correction?.conflict_description.replace('{source}', conflictInfo?.source_term || '').replace('{existing}', conflictInfo?.existing_target_term || '')} {_t.translate?.term_correction?.conflict_description.replace('{source}', conflictInfo?.source_term || '').replace('{existing}', conflictInfo?.existing_target_term || '')}
</p> </p>
</div> </div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button onclick={handleConflictKeep} class="px-4 py-2 text-sm text-text bg-surface-muted rounded-lg hover:bg-surface-muted"> <button onclick={handleConflictKeep} class="px-4 py-2 text-sm text-text bg-surface-muted rounded-lg hover:bg-surface-muted">
{$t.translate?.term_correction?.keep_existing} {_t.translate?.term_correction?.keep_existing}
</button> </button>
<button onclick={handleConflictOverwrite} class="px-4 py-2 text-sm text-white bg-yellow-600 rounded-lg hover:bg-yellow-700"> <button onclick={handleConflictOverwrite} class="px-4 py-2 text-sm text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
{$t.translate?.term_correction?.overwrite} {_t.translate?.term_correction?.overwrite}
</button> </button>
</div> </div>
</div> </div>
@@ -329,10 +332,10 @@
<svg class="w-12 h-12 text-success mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-12 h-12 text-success mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg> </svg>
<p class="text-sm font-medium text-text">{$t.translate?.term_correction?.submitted}</p> <p class="text-sm font-medium text-text">{_t.translate?.term_correction?.submitted}</p>
<p class="text-xs text-text-muted mt-1">{submitResult?.message || ''}</p> <p class="text-xs text-text-muted mt-1">{submitResult?.message || ''}</p>
<button onclick={handleClose} class="mt-4 px-4 py-2 text-sm text-white bg-primary rounded-lg hover:bg-primary-hover"> <button onclick={handleClose} class="mt-4 px-4 py-2 text-sm text-white bg-primary rounded-lg hover:bg-primary-hover">
{$t.translate?.term_correction?.close} {_t.translate?.term_correction?.close}
</button> </button>
</div> </div>
{/if} {/if}

View File

@@ -8,12 +8,15 @@
<!-- @UX_FEEDBACK Spinner during load; card grid for totals; table for per-job breakdown --> <!-- @UX_FEEDBACK Spinner during load; card grid for totals; table for per-job breakdown -->
<!-- @UX_RECOVERY Retry button on error --> <!-- @UX_RECOVERY Retry button on error -->
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n/index.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js';
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
import { fetchAllMetrics } from '$lib/api/translate.js'; import { fetchAllMetrics } from '$lib/api/translate.js';
/** @type {'loading'|'loaded'|'error'|'empty'} */ /** @type {'loading'|'loaded'|'error'|'empty'} */
let uxState = $state('loading'); let uxState = $state('loading');
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
let metrics = $state([]); let metrics = $state([]);
let errorMessage = $state(''); let errorMessage = $state('');
@@ -52,7 +55,7 @@
uxState = 'loaded'; uxState = 'loaded';
} }
} catch (err) { } catch (err) {
errorMessage = err?.message || $t.translate?.metrics?.load_failed; errorMessage = err?.message || getT()?.translate?.metrics?.load_failed;
uxState = 'error'; uxState = 'error';
} }
} }
@@ -66,8 +69,8 @@
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-xl font-bold text-text">{$t.translate?.metrics?.title}</h2> <h2 class="text-xl font-bold text-text">{_t.translate?.metrics?.title}</h2>
<p class="text-sm text-text-muted mt-1">{$t.translate?.metrics?.subtitle}</p> <p class="text-sm text-text-muted mt-1">{_t.translate?.metrics?.subtitle}</p>
</div> </div>
<button <button
onclick={handleRefresh} onclick={handleRefresh}
@@ -76,7 +79,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg> </svg>
{$t.translate?.metrics?.refresh} {_t.translate?.metrics?.refresh}
</button> </button>
</div> </div>
@@ -96,7 +99,7 @@
onclick={handleRefresh} onclick={handleRefresh}
class="px-4 py-2 bg-destructive text-white rounded-lg hover:bg-destructive-hover transition-colors text-sm" class="px-4 py-2 bg-destructive text-white rounded-lg hover:bg-destructive-hover transition-colors text-sm"
> >
{$t.translate?.common?.retry} {_t.translate?.common?.retry}
</button> </button>
</div> </div>
@@ -106,7 +109,7 @@
<svg class="w-16 h-16 text-text-subtle mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-16 h-16 text-text-subtle mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg> </svg>
<p class="text-text-muted">{$t.translate?.metrics?.no_data}</p> <p class="text-text-muted">{_t.translate?.metrics?.no_data}</p>
</div> </div>
<!-- Loaded --> <!-- Loaded -->
@@ -114,27 +117,27 @@
<!-- Summary Cards --> <!-- Summary Cards -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4"> <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div class="bg-surface-card border border-border rounded-lg p-4"> <div class="bg-surface-card border border-border rounded-lg p-4">
<p class="text-xs text-text-muted uppercase">{$t.translate?.metrics?.total_runs}</p> <p class="text-xs text-text-muted uppercase">{_t.translate?.metrics?.total_runs}</p>
<p class="text-2xl font-bold text-text mt-1">{totalRuns}</p> <p class="text-2xl font-bold text-text mt-1">{totalRuns}</p>
</div> </div>
<div class="bg-surface-card border border-border rounded-lg p-4"> <div class="bg-surface-card border border-border rounded-lg p-4">
<p class="text-xs text-text-muted uppercase">{$t.translate?.metrics?.success_rate}</p> <p class="text-xs text-text-muted uppercase">{_t.translate?.metrics?.success_rate}</p>
<p class="text-2xl font-bold text-success mt-1">{successRate}%</p> <p class="text-2xl font-bold text-success mt-1">{successRate}%</p>
</div> </div>
<div class="bg-surface-card border border-border rounded-lg p-4"> <div class="bg-surface-card border border-border rounded-lg p-4">
<p class="text-xs text-text-muted uppercase">{$t.translate?.metrics?.failed_runs}</p> <p class="text-xs text-text-muted uppercase">{_t.translate?.metrics?.failed_runs}</p>
<p class="text-2xl font-bold text-destructive mt-1">{failedRuns}</p> <p class="text-2xl font-bold text-destructive mt-1">{failedRuns}</p>
</div> </div>
<div class="bg-surface-card border border-border rounded-lg p-4"> <div class="bg-surface-card border border-border rounded-lg p-4">
<p class="text-xs text-text-muted uppercase">{$t.translate?.metrics?.total_records}</p> <p class="text-xs text-text-muted uppercase">{_t.translate?.metrics?.total_records}</p>
<p class="text-2xl font-bold text-text mt-1">{totalRecords.toLocaleString()}</p> <p class="text-2xl font-bold text-text mt-1">{totalRecords.toLocaleString()}</p>
</div> </div>
<div class="bg-surface-card border border-border rounded-lg p-4"> <div class="bg-surface-card border border-border rounded-lg p-4">
<p class="text-xs text-text-muted uppercase">{$t.translate?.metrics?.total_tokens}</p> <p class="text-xs text-text-muted uppercase">{_t.translate?.metrics?.total_tokens}</p>
<p class="text-2xl font-bold text-text mt-1">{totalTokens.toLocaleString()}</p> <p class="text-2xl font-bold text-text mt-1">{totalTokens.toLocaleString()}</p>
</div> </div>
<div class="bg-surface-card border border-border rounded-lg p-4"> <div class="bg-surface-card border border-border rounded-lg p-4">
<p class="text-xs text-text-muted uppercase">{$t.translate?.metrics?.total_cost}</p> <p class="text-xs text-text-muted uppercase">{_t.translate?.metrics?.total_cost}</p>
<p class="text-2xl font-bold text-text mt-1">${totalCost.toFixed(2)}</p> <p class="text-2xl font-bold text-text mt-1">${totalCost.toFixed(2)}</p>
</div> </div>
</div> </div>
@@ -142,18 +145,18 @@
<!-- Per-Job Breakdown Table --> <!-- Per-Job Breakdown Table -->
<div class="bg-surface-card border border-border rounded-lg overflow-hidden"> <div class="bg-surface-card border border-border rounded-lg overflow-hidden">
<div class="px-4 py-3 border-b border-border"> <div class="px-4 py-3 border-b border-border">
<h3 class="text-sm font-semibold text-text">{$t.translate?.metrics?.runs_per_job}</h3> <h3 class="text-sm font-semibold text-text">{_t.translate?.metrics?.runs_per_job}</h3>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-surface-muted"> <thead class="bg-surface-muted">
<tr> <tr>
<th class="px-4 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.translate?.metrics?.job}</th> <th class="px-4 py-2 text-left text-xs font-medium text-text-muted uppercase">{_t.translate?.metrics?.job}</th>
<th class="px-4 py-2 text-center text-xs font-medium text-text-muted uppercase">{$t.translate?.metrics?.runs}</th> <th class="px-4 py-2 text-center text-xs font-medium text-text-muted uppercase">{_t.translate?.metrics?.runs}</th>
<th class="px-4 py-2 text-center text-xs font-medium text-text-muted uppercase">{$t.translate?.metrics?.successful}</th> <th class="px-4 py-2 text-center text-xs font-medium text-text-muted uppercase">{_t.translate?.metrics?.successful}</th>
<th class="px-4 py-2 text-center text-xs font-medium text-text-muted uppercase">{$t.translate?.metrics?.failed}</th> <th class="px-4 py-2 text-center text-xs font-medium text-text-muted uppercase">{_t.translate?.metrics?.failed}</th>
<th class="px-4 py-2 text-right text-xs font-medium text-text-muted uppercase">{$t.translate?.metrics?.tokens}</th> <th class="px-4 py-2 text-right text-xs font-medium text-text-muted uppercase">{_t.translate?.metrics?.tokens}</th>
<th class="px-4 py-2 text-right text-xs font-medium text-text-muted uppercase">{$t.translate?.metrics?.cost}</th> <th class="px-4 py-2 text-right text-xs font-medium text-text-muted uppercase">{_t.translate?.metrics?.cost}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-border"> <tbody class="divide-y divide-border">
@@ -175,7 +178,7 @@
<!-- Additional stats row --> <!-- Additional stats row -->
{#if avgLatency !== null} {#if avgLatency !== null}
<div class="bg-surface-card border border-border rounded-lg p-4"> <div class="bg-surface-card border border-border rounded-lg p-4">
<p class="text-xs text-text-muted uppercase">{$t.translate?.metrics?.avg_latency}</p> <p class="text-xs text-text-muted uppercase">{_t.translate?.metrics?.avg_latency}</p>
<p class="text-lg font-semibold text-text mt-1">{avgLatency}s</p> <p class="text-lg font-semibold text-text mt-1">{avgLatency}s</p>
</div> </div>
{/if} {/if}

View File

@@ -11,7 +11,7 @@
<!-- @UX_FEEDBACK spinner during preview; visual distinction for LLM vs edited translations; cost warning for large previews. --> <!-- @UX_FEEDBACK spinner during preview; visual distinction for LLM vs edited translations; cost warning for large previews. -->
<!-- @UX_RECOVERY retry preview; re-fetch with updated config. --> <!-- @UX_RECOVERY retry preview; re-fetch with updated config. -->
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n/index.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js';
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
import { import {
fetchPreview, fetchPreview,
@@ -27,6 +27,9 @@
/** @type {'idle'|'loading'|'preview_loaded'|'preview_error'|'accepted'|'stale_config'} */ /** @type {'idle'|'loading'|'preview_loaded'|'preview_error'|'accepted'|'stale_config'} */
let uxState = $state('idle'); let uxState = $state('idle');
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
let session = $state(null); let session = $state(null);
let records = $state([]); let records = $state([]);
let targetLanguages = $state([]); let targetLanguages = $state([]);
@@ -47,9 +50,9 @@
function getRowStatusLabel(status) { function getRowStatusLabel(status) {
const labels = { const labels = {
APPROVED: $t.translate?.preview?.status_approved || 'Approved', APPROVED: getT()?.translate?.preview?.status_approved || 'Approved',
REJECTED: $t.translate?.preview?.status_rejected || 'Rejected', REJECTED: getT()?.translate?.preview?.status_rejected || 'Rejected',
PENDING: $t.translate?.preview?.status_pending || 'Pending', PENDING: getT()?.translate?.preview?.status_pending || 'Pending',
}; };
return labels[status] || status; return labels[status] || status;
} }
@@ -91,9 +94,9 @@
targetLanguages = result.target_languages || []; targetLanguages = result.target_languages || [];
costEstimate = result.cost_estimate || null; costEstimate = result.cost_estimate || null;
uxState = 'preview_loaded'; uxState = 'preview_loaded';
addToast($t.translate?.preview?.preview_loaded?.replace('{count}', records.length), 'success'); addToast(getT()?.translate?.preview?.preview_loaded?.replace('{count}', records.length), 'success');
} catch (err) { } catch (err) {
errorMessage = err?.message || $t.translate?.preview?.preview_failed; errorMessage = err?.message || getT()?.translate?.preview?.preview_failed;
uxState = 'preview_error'; uxState = 'preview_error';
addToast(errorMessage, 'error'); addToast(errorMessage, 'error');
} }
@@ -111,7 +114,7 @@
_isEdited: false _isEdited: false
} : r); } : r);
} catch (err) { } catch (err) {
addToast(err?.message || $t.translate?.preview?.approve_failed, 'error'); addToast(err?.message || getT()?.translate?.preview?.approve_failed, 'error');
} }
} }
@@ -126,7 +129,7 @@
_isEdited: false _isEdited: false
} : r); } : r);
} catch (err) { } catch (err) {
addToast(err?.message || $t.translate?.preview?.reject_failed, 'error'); addToast(err?.message || getT()?.translate?.preview?.reject_failed, 'error');
} }
} }
@@ -142,7 +145,7 @@
_isEdited: false _isEdited: false
} : r); } : r);
} catch (err) { } catch (err) {
addToast(err?.message || $t.translate?.preview?.approve_failed, 'error'); addToast(err?.message || getT()?.translate?.preview?.approve_failed, 'error');
} }
} }
@@ -157,7 +160,7 @@
_isEdited: false _isEdited: false
} : r); } : r);
} catch (err) { } catch (err) {
addToast(err?.message || $t.translate?.preview?.reject_failed, 'error'); addToast(err?.message || getT()?.translate?.preview?.reject_failed, 'error');
} }
} }
@@ -192,9 +195,9 @@
const newValues = { ...editValues }; const newValues = { ...editValues };
delete newValues[key]; delete newValues[key];
editValues = newValues; editValues = newValues;
addToast($t.translate?.preview?.row_updated, 'success'); addToast(getT()?.translate?.preview?.row_updated, 'success');
} catch (err) { } catch (err) {
addToast(err?.message || $t.translate?.preview?.edit_failed, 'error'); addToast(err?.message || getT()?.translate?.preview?.edit_failed, 'error');
} }
} }
@@ -223,9 +226,9 @@
const result = await acceptPreview(jobId); const result = await acceptPreview(jobId);
uxState = 'accepted'; uxState = 'accepted';
onAccept(); onAccept();
addToast($t.translate?.preview?.accepted_body, 'success'); addToast(getT()?.translate?.preview?.accepted_body, 'success');
} catch (err) { } catch (err) {
addToast(err?.message || $t.translate?.preview?.accept_failed, 'error'); addToast(err?.message || getT()?.translate?.preview?.accept_failed, 'error');
} finally { } finally {
isAccepting = false; isAccepting = false;
} }
@@ -235,10 +238,10 @@
<div class="translation-preview"> <div class="translation-preview">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-text">{$t.translate?.preview?.title}</h3> <h3 class="text-lg font-semibold text-text">{_t.translate?.preview?.title}</h3>
{#if uxState === 'preview_loaded'} {#if uxState === 'preview_loaded'}
<span class="px-2.5 py-0.5 rounded-full text-xs font-medium {session?.status === 'APPLIED' ? 'bg-success-light text-success' : 'bg-primary-light text-primary'}"> <span class="px-2.5 py-0.5 rounded-full text-xs font-medium {session?.status === 'APPLIED' ? 'bg-success-light text-success' : 'bg-primary-light text-primary'}">
{session?.status === 'APPLIED' ? $t.translate?.preview?.accepted_badge : $t.translate?.preview?.active_badge} {session?.status === 'APPLIED' ? _t.translate?.preview?.accepted_badge : _t.translate?.preview?.active_badge}
</span> </span>
{/if} {/if}
</div> </div>
@@ -247,10 +250,10 @@
{#if uxState === 'idle'} {#if uxState === 'idle'}
<div class="bg-surface-muted border border-border rounded-lg p-6 text-center"> <div class="bg-surface-muted border border-border rounded-lg p-6 text-center">
<p class="text-sm text-text-muted mb-4"> <p class="text-sm text-text-muted mb-4">
{$t.translate?.preview?.title} &mdash; {$t.translate?.preview?.sample_size} {sampleSize} {_t.translate?.preview?.title} &mdash; {_t.translate?.preview?.sample_size} {sampleSize}
</p> </p>
<div class="flex items-center justify-center gap-3 mb-4"> <div class="flex items-center justify-center gap-3 mb-4">
<label class="text-sm text-text-muted">{$t.translate?.preview?.sample_size}</label> <label class="text-sm text-text-muted">{_t.translate?.preview?.sample_size}</label>
<input <input
type="range" type="range"
bind:value={sampleSize} bind:value={sampleSize}
@@ -265,14 +268,14 @@
<svg class="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg> </svg>
<span class="text-xs font-medium">{($t.translate?.preview?.cost_warning) || 'Large sample size may incur higher costs'}</span> <span class="text-xs font-medium">{(_t.translate?.preview?.cost_warning) || 'Large sample size may incur higher costs'}</span>
</div> </div>
{/if} {/if}
<button <button
onclick={handlePreview} onclick={handlePreview}
class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors text-sm" class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors text-sm"
> >
{$t.translate?.preview?.run_preview} {_t.translate?.preview?.run_preview}
</button> </button>
</div> </div>
@@ -281,7 +284,7 @@
<div class="bg-surface-card border border-border rounded-lg p-6"> <div class="bg-surface-card border border-border rounded-lg p-6">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-primary-ring" /> <div class="animate-spin rounded-full h-5 w-5 border-b-2 border-primary-ring" />
<span class="text-sm text-text-muted">{$t.translate?.preview?.running_preview}</span> <span class="text-sm text-text-muted">{_t.translate?.preview?.running_preview}</span>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
{#each Array(3) as _} {#each Array(3) as _}
@@ -299,10 +302,10 @@
onclick={handlePreview} onclick={handlePreview}
class="px-4 py-1.5 bg-destructive text-white rounded-lg hover:bg-destructive-hover transition-colors text-sm" class="px-4 py-1.5 bg-destructive text-white rounded-lg hover:bg-destructive-hover transition-colors text-sm"
> >
{$t.translate?.preview?.retry_preview} {_t.translate?.preview?.retry_preview}
</button> </button>
<label class="text-sm text-text-muted flex items-center gap-2"> <label class="text-sm text-text-muted flex items-center gap-2">
{$t.translate?.preview?.sample_size} {_t.translate?.preview?.sample_size}
<input <input
type="range" type="range"
bind:value={sampleSize} bind:value={sampleSize}
@@ -320,28 +323,28 @@
<!-- Cost Estimate Card --> <!-- Cost Estimate Card -->
{#if costEstimate} {#if costEstimate}
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 border border-primary-ring rounded-lg p-4 mb-4"> <div class="bg-gradient-to-r from-blue-50 to-indigo-50 border border-primary-ring rounded-lg p-4 mb-4">
<h4 class="text-sm font-medium text-primary mb-2">{$t.translate?.preview?.cost_estimate}</h4> <h4 class="text-sm font-medium text-primary mb-2">{_t.translate?.preview?.cost_estimate}</h4>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm"> <div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
<div> <div>
<span class="text-primary">{$t.translate?.preview?.sample_rows}</span> <span class="text-primary">{_t.translate?.preview?.sample_rows}</span>
<span class="ml-1 font-semibold">{costEstimate.sample_size}</span> <span class="ml-1 font-semibold">{costEstimate.sample_size}</span>
</div> </div>
<div> <div>
<span class="text-primary">{$t.translate?.preview?.sample_tokens}</span> <span class="text-primary">{_t.translate?.preview?.sample_tokens}</span>
<span class="ml-1 font-semibold">{costEstimate.sample_total_tokens?.toLocaleString()}</span> <span class="ml-1 font-semibold">{costEstimate.sample_total_tokens?.toLocaleString()}</span>
</div> </div>
<div> <div>
<span class="text-primary">{$t.translate?.preview?.sample_cost}</span> <span class="text-primary">{_t.translate?.preview?.sample_cost}</span>
<span class="ml-1 font-semibold">${costEstimate.sample_cost?.toFixed(6)}</span> <span class="ml-1 font-semibold">${costEstimate.sample_cost?.toFixed(6)}</span>
</div> </div>
<div> <div>
<span class="text-primary">{$t.translate?.preview?.est_total_cost}</span> <span class="text-primary">{_t.translate?.preview?.est_total_cost}</span>
<span class="ml-1 font-semibold">${costEstimate.estimated_cost?.toFixed(6)}</span> <span class="ml-1 font-semibold">${costEstimate.estimated_cost?.toFixed(6)}</span>
</div> </div>
</div> </div>
<!-- Language count --> <!-- Language count -->
<p class="text-xs text-primary mt-1"> <p class="text-xs text-primary mt-1">
{$t.translate?.preview?.across_languages?.replace('{count}', costEstimate.num_languages || targetLanguages.length || 1)} {_t.translate?.preview?.across_languages?.replace('{count}', costEstimate.num_languages || targetLanguages.length || 1)}
</p> </p>
<!-- Cost warning for large previews --> <!-- Cost warning for large previews -->
{#if costEstimate.warning} {#if costEstimate.warning}
@@ -358,14 +361,14 @@
<!-- Stats Bar --> <!-- Stats Bar -->
<div class="flex items-center justify-between mb-3 text-sm"> <div class="flex items-center justify-between mb-3 text-sm">
<div class="flex gap-4"> <div class="flex gap-4">
<span class="text-text-muted">{$t.translate?.preview?.rows_count?.replace('{count}', records.length)}</span> <span class="text-text-muted">{_t.translate?.preview?.rows_count?.replace('{count}', records.length)}</span>
<span class="text-success">{$t.translate?.preview?.approved_count?.replace('{count}', approvedCount)}</span> <span class="text-success">{_t.translate?.preview?.approved_count?.replace('{count}', approvedCount)}</span>
<span class="text-destructive">{$t.translate?.preview?.rejected_count?.replace('{count}', rejectedCount)}</span> <span class="text-destructive">{_t.translate?.preview?.rejected_count?.replace('{count}', rejectedCount)}</span>
{#if pendingCount > 0} {#if pendingCount > 0}
<span class="text-warning">{$t.translate?.preview?.pending_count?.replace('{count}', pendingCount)}</span> <span class="text-warning">{_t.translate?.preview?.pending_count?.replace('{count}', pendingCount)}</span>
{/if} {/if}
{#if targetLanguages.length > 0} {#if targetLanguages.length > 0}
<span class="text-primary">{$t.translate?.preview?.languages_count?.replace('{count}', targetLanguages.length)}</span> <span class="text-primary">{_t.translate?.preview?.languages_count?.replace('{count}', targetLanguages.length)}</span>
{/if} {/if}
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -374,14 +377,14 @@
onclick={handleBulkApprove} onclick={handleBulkApprove}
class="px-3 py-1 text-xs bg-success-light text-success rounded hover:bg-success-light transition-colors" class="px-3 py-1 text-xs bg-success-light text-success rounded hover:bg-success-light transition-colors"
> >
{$t.translate?.preview?.approve_all} {_t.translate?.preview?.approve_all}
</button> </button>
{/if} {/if}
<button <button
onclick={handlePreview} onclick={handlePreview}
class="px-3 py-1 text-xs border border-border-strong text-text-muted rounded hover:bg-surface-muted transition-colors" class="px-3 py-1 text-xs border border-border-strong text-text-muted rounded hover:bg-surface-muted transition-colors"
> >
{$t.translate?.preview?.re_run} {_t.translate?.preview?.re_run}
</button> </button>
</div> </div>
</div> </div>
@@ -392,8 +395,8 @@
<thead class="bg-surface-muted"> <thead class="bg-surface-muted">
<tr> <tr>
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase w-12">#</th> <th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase w-12">#</th>
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.translate?.preview?.table_source}</th> <th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{_t.translate?.preview?.table_source}</th>
<th class="px-3 py-2 text-center text-xs font-medium text-text-muted uppercase w-28">{$t.translate?.preview?.detected_language}</th> <th class="px-3 py-2 text-center text-xs font-medium text-text-muted uppercase w-28">{_t.translate?.preview?.detected_language}</th>
{#each targetLanguages as lang} {#each targetLanguages as lang}
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase min-w-[250px]"> <th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase min-w-[250px]">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@@ -401,7 +404,7 @@
</div> </div>
</th> </th>
{/each} {/each}
<th class="px-3 py-2 text-center text-xs font-medium text-text-muted uppercase w-24">{$t.translate?.preview?.table_status}</th> <th class="px-3 py-2 text-center text-xs font-medium text-text-muted uppercase w-24">{_t.translate?.preview?.table_status}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-border"> <tbody class="divide-y divide-border">
@@ -410,7 +413,7 @@
<td class="px-3 py-2 text-xs text-text-subtle align-top">{idx + 1}</td> <td class="px-3 py-2 text-xs text-text-subtle align-top">{idx + 1}</td>
<td class="px-3 py-2 align-top min-w-[200px]"> <td class="px-3 py-2 align-top min-w-[200px]">
<div class="max-h-32 overflow-y-auto"> <div class="max-h-32 overflow-y-auto">
<code class="text-xs text-text whitespace-pre-wrap break-all">{row.source_sql || $t.translate?.preview?.empty_placeholder}</code> <code class="text-xs text-text whitespace-pre-wrap break-all">{row.source_sql || _t.translate?.preview?.empty_placeholder}</code>
</div> </div>
</td> </td>
<td class="px-3 py-2 text-center align-top"> <td class="px-3 py-2 text-center align-top">
@@ -439,19 +442,19 @@
onclick={() => saveLangEdit(row.id, lang)} onclick={() => saveLangEdit(row.id, lang)}
class="px-2 py-0.5 text-xs bg-primary text-white rounded hover:bg-primary-hover" class="px-2 py-0.5 text-xs bg-primary text-white rounded hover:bg-primary-hover"
> >
{$t.translate?.preview?.save_edit} {_t.translate?.preview?.save_edit}
</button> </button>
<button <button
onclick={() => cancelLangEdit(row.id, lang)} onclick={() => cancelLangEdit(row.id, lang)}
class="px-2 py-0.5 text-xs border border-border-strong text-text-muted rounded hover:bg-surface-muted" class="px-2 py-0.5 text-xs border border-border-strong text-text-muted rounded hover:bg-surface-muted"
> >
{$t.translate?.preview?.cancel_edit} {_t.translate?.preview?.cancel_edit}
</button> </button>
</div> </div>
{:else} {:else}
<div class="max-h-32 overflow-y-auto"> <div class="max-h-32 overflow-y-auto">
<code class="text-xs whitespace-pre-wrap break-all {langStatus === 'edited' ? 'text-warning' : langStatus === 'approved' ? 'text-success' : 'text-text'}"> <code class="text-xs whitespace-pre-wrap break-all {langStatus === 'edited' ? 'text-warning' : langStatus === 'approved' ? 'text-success' : 'text-text'}">
{langVal || $t.translate?.preview?.pending_placeholder} {langVal || _t.translate?.preview?.pending_placeholder}
</code> </code>
</div> </div>
<div class="flex gap-1 mt-1 flex-wrap"> <div class="flex gap-1 mt-1 flex-wrap">
@@ -459,25 +462,25 @@
<button <button
onclick={() => handleApproveLanguage(row.id, lang)} onclick={() => handleApproveLanguage(row.id, lang)}
class="px-1.5 py-0.5 text-xs bg-success-light text-success rounded hover:bg-success-light transition-colors" class="px-1.5 py-0.5 text-xs bg-success-light text-success rounded hover:bg-success-light transition-colors"
title={$t.translate?.preview?.approve} title={_t.translate?.preview?.approve}
> >
{$t.translate?.preview?.approve} {_t.translate?.preview?.approve}
</button> </button>
{/if} {/if}
<button <button
onclick={() => startLangEdit(row.id, lang)} onclick={() => startLangEdit(row.id, lang)}
class="px-1.5 py-0.5 text-xs bg-primary-light text-primary rounded hover:bg-primary-light transition-colors" class="px-1.5 py-0.5 text-xs bg-primary-light text-primary rounded hover:bg-primary-light transition-colors"
title={$t.translate?.preview?.edit} title={_t.translate?.preview?.edit}
> >
{$t.translate?.preview?.edit} {_t.translate?.preview?.edit}
</button> </button>
{#if langStatus !== 'rejected'} {#if langStatus !== 'rejected'}
<button <button
onclick={() => handleRejectLanguage(row.id, lang)} onclick={() => handleRejectLanguage(row.id, lang)}
class="px-1.5 py-0.5 text-xs bg-destructive-light text-destructive rounded hover:bg-destructive-light transition-colors" class="px-1.5 py-0.5 text-xs bg-destructive-light text-destructive rounded hover:bg-destructive-light transition-colors"
title={$t.translate?.preview?.reject} title={_t.translate?.preview?.reject}
> >
{$t.translate?.preview?.reject} {_t.translate?.preview?.reject}
</button> </button>
{/if} {/if}
</div> </div>
@@ -498,18 +501,18 @@
<button <button
onclick={() => handleApprove(row.id)} onclick={() => handleApprove(row.id)}
class="px-1.5 py-0.5 text-xs bg-success-light text-success rounded hover:bg-success-light transition-colors" class="px-1.5 py-0.5 text-xs bg-success-light text-success rounded hover:bg-success-light transition-colors"
title={$t.translate?.preview?.approve_all} title={_t.translate?.preview?.approve_all}
> >
{$t.translate?.preview?.approve} {_t.translate?.preview?.approve}
</button> </button>
{/if} {/if}
{#if row.status !== 'REJECTED'} {#if row.status !== 'REJECTED'}
<button <button
onclick={() => handleReject(row.id)} onclick={() => handleReject(row.id)}
class="px-1.5 py-0.5 text-xs bg-destructive-light text-destructive rounded hover:bg-destructive-light transition-colors" class="px-1.5 py-0.5 text-xs bg-destructive-light text-destructive rounded hover:bg-destructive-light transition-colors"
title={$t.translate?.preview?.reject} title={_t.translate?.preview?.reject}
> >
{$t.translate?.preview?.reject} {_t.translate?.preview?.reject}
</button> </button>
{/if} {/if}
</div> </div>
@@ -524,9 +527,9 @@
<!-- Accept Gate --> <!-- Accept Gate -->
<div class="mt-6 flex items-center justify-between p-4 bg-surface-muted border border-border rounded-lg"> <div class="mt-6 flex items-center justify-between p-4 bg-surface-muted border border-border rounded-lg">
<div> <div>
<p class="text-sm font-medium text-text">{$t.translate?.preview?.quality_gate}</p> <p class="text-sm font-medium text-text">{_t.translate?.preview?.quality_gate}</p>
<p class="text-xs text-text-muted"> <p class="text-xs text-text-muted">
{isAllApproved ? $t.translate?.preview?.quality_gate_ready : $t.translate?.preview?.quality_gate_pending} {isAllApproved ? _t.translate?.preview?.quality_gate_ready : _t.translate?.preview?.quality_gate_pending}
</p> </p>
</div> </div>
<button <button
@@ -537,7 +540,7 @@
? 'bg-success text-white hover:bg-success' ? 'bg-success text-white hover:bg-success'
: 'bg-surface-muted text-text-subtle cursor-not-allowed'}" : 'bg-surface-muted text-text-subtle cursor-not-allowed'}"
> >
{isAccepting ? $t.translate?.preview?.accepting : $t.translate?.preview?.accept_preview} {isAccepting ? _t.translate?.preview?.accepting : _t.translate?.preview?.accept_preview}
</button> </button>
</div> </div>
@@ -545,15 +548,15 @@
{:else if uxState === 'accepted'} {:else if uxState === 'accepted'}
<div class="bg-success-light border border-success rounded-lg p-6 text-center"> <div class="bg-success-light border border-success rounded-lg p-6 text-center">
<div class="text-3xl mb-2">&#10003;</div> <div class="text-3xl mb-2">&#10003;</div>
<h4 class="text-lg font-semibold text-success mb-1">{$t.translate?.preview?.accepted_title}</h4> <h4 class="text-lg font-semibold text-success mb-1">{_t.translate?.preview?.accepted_title}</h4>
<p class="text-sm text-success"> <p class="text-sm text-success">
{$t.translate?.preview?.accepted_body} {_t.translate?.preview?.accepted_body}
</p> </p>
<button <button
onclick={handlePreview} onclick={handlePreview}
class="mt-4 px-4 py-1.5 text-sm border border-success text-success rounded-lg hover:bg-success-light transition-colors" class="mt-4 px-4 py-1.5 text-sm border border-success text-success rounded-lg hover:bg-success-light transition-colors"
> >
{$t.translate?.preview?.run_preview_again} {_t.translate?.preview?.run_preview_again}
</button> </button>
</div> </div>
{/if} {/if}

View File

@@ -31,7 +31,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { ROUTES } from '$lib/routes'; import { ROUTES } from '$lib/routes';
import { t } from '$lib/i18n/index.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { cancelRun } from '$lib/api/translate.js'; import { cancelRun } from '$lib/api/translate.js';
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
@@ -43,6 +43,9 @@
// @RATIONALE $effect(() => store.subscribe(s => state = s)) creates exactly ONE // @RATIONALE $effect(() => store.subscribe(s => state = s)) creates exactly ONE
// subscription per component lifecycle — no accumulating render_effects. // subscription per component lifecycle — no accumulating render_effects.
let storeState = $state({ uxState: 'idle', totalRecords: 0, successfulRecords: 0, failedRecords: 0, skippedRecords: 0, progressPct: 0, runId: null, jobId: null }); let storeState = $state({ uxState: 'idle', totalRecords: 0, successfulRecords: 0, failedRecords: 0, skippedRecords: 0, progressPct: 0, runId: null, jobId: null });
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
$effect(() => { $effect(() => {
const unsub = translationRunStore.subscribe(s => { storeState = s; }); const unsub = translationRunStore.subscribe(s => { storeState = s; });
return () => unsub(); return () => unsub();
@@ -73,12 +76,12 @@
let isActive = $derived(uxState === 'running' || uxState === 'inserting'); let isActive = $derived(uxState === 'running' || uxState === 'inserting');
let label = $derived.by(() => { let label = $derived.by(() => {
if (uxState === 'inserting') return $t.translate?.run?.insert_phase || 'Inserting...'; if (uxState === 'inserting') return getT()?.translate?.run?.insert_phase || 'Inserting...';
if (uxState === 'running') return $t.translate?.run?.translate_phase || 'Translating...'; if (uxState === 'running') return getT()?.translate?.run?.translate_phase || 'Translating...';
if (uxState === 'completed') return $t.translate?.run?.completed || 'Completed'; if (uxState === 'completed') return getT()?.translate?.run?.completed || 'Completed';
if (uxState === 'partial') return $t.translate?.run?.completed_with_errors || 'Completed with errors'; if (uxState === 'partial') return getT()?.translate?.run?.completed_with_errors || 'Completed with errors';
if (uxState === 'failed') return $t.translate?.run?.translation_failed || 'Translation failed'; if (uxState === 'failed') return getT()?.translate?.run?.translation_failed || 'Translation failed';
if (uxState === 'cancelled') return $t.translate?.run?.cancelled || 'Cancelled'; if (uxState === 'cancelled') return getT()?.translate?.run?.cancelled || 'Cancelled';
return ''; return '';
}); });
@@ -122,9 +125,9 @@
isCancelling = true; isCancelling = true;
try { try {
await cancelRun(runId); await cancelRun(runId);
addToast($t.translate?.run?.run_cancelled || 'Run cancelled', 'info'); addToast(getT()?.translate?.run?.run_cancelled || 'Run cancelled', 'info');
} catch (err) { } catch (err) {
addToast(err?.message || $t.translate?.run?.cancel_failed || 'Cancel failed', 'error'); addToast(err?.message || getT()?.translate?.run?.cancel_failed || 'Cancel failed', 'error');
} finally { } finally {
isCancelling = false; isCancelling = false;
} }
@@ -152,7 +155,7 @@
onclick={handleClick} onclick={handleClick}
class="text-xs text-primary hover:text-primary underline" class="text-xs text-primary hover:text-primary underline"
> >
{$t.common?.open || 'Open'} {_t.common?.open || 'Open'}
</button> </button>
<button <button
onclick={handleCancel} onclick={handleCancel}
@@ -160,8 +163,8 @@
class="px-2 py-1 text-xs border border-destructive-ring text-destructive rounded hover:bg-destructive-light disabled:opacity-50 transition-colors" class="px-2 py-1 text-xs border border-destructive-ring text-destructive rounded hover:bg-destructive-light disabled:opacity-50 transition-colors"
> >
{isCancelling {isCancelling
? ($t.translate?.run?.cancelling || 'Cancelling...') ? (_t.translate?.run?.cancelling || 'Cancelling...')
: ($t.translate?.run?.cancel || 'Cancel')} : (_t.translate?.run?.cancel || 'Cancel')}
</button> </button>
</div> </div>
</div> </div>
@@ -177,19 +180,19 @@
<!-- Stats grid --> <!-- Stats grid -->
<div class="grid grid-cols-5 gap-2 text-xs"> <div class="grid grid-cols-5 gap-2 text-xs">
<div class="bg-surface-muted rounded px-2 py-1 text-center"> <div class="bg-surface-muted rounded px-2 py-1 text-center">
<span class="text-text-muted block">{($t.translate?.run?.total || 'Total')}</span> <span class="text-text-muted block">{(_t.translate?.run?.total || 'Total')}</span>
<span class="font-bold text-text">{totalRecords}</span> <span class="font-bold text-text">{totalRecords}</span>
</div> </div>
<div class="bg-success-light rounded px-2 py-1 text-center"> <div class="bg-success-light rounded px-2 py-1 text-center">
<span class="text-success block">{($t.translate?.run?.success || 'OK')}</span> <span class="text-success block">{(_t.translate?.run?.success || 'OK')}</span>
<span class="font-bold text-success">{successfulRecords}</span> <span class="font-bold text-success">{successfulRecords}</span>
</div> </div>
<div class="bg-destructive-light rounded px-2 py-1 text-center"> <div class="bg-destructive-light rounded px-2 py-1 text-center">
<span class="text-destructive block">{($t.translate?.run?.failed || 'Fail')}</span> <span class="text-destructive block">{(_t.translate?.run?.failed || 'Fail')}</span>
<span class="font-bold text-destructive">{failedRecords}</span> <span class="font-bold text-destructive">{failedRecords}</span>
</div> </div>
<div class="bg-warning-light rounded px-2 py-1 text-center"> <div class="bg-warning-light rounded px-2 py-1 text-center">
<span class="text-warning block">{($t.translate?.run?.skipped || 'Skip')}</span> <span class="text-warning block">{(_t.translate?.run?.skipped || 'Skip')}</span>
<span class="font-bold text-warning">{skippedRecords}</span> <span class="font-bold text-warning">{skippedRecords}</span>
</div> </div>
<div class="bg-info-light rounded px-2 py-1 text-center"> <div class="bg-info-light rounded px-2 py-1 text-center">
@@ -225,7 +228,7 @@
{/if} {/if}
</div> </div>
<span class="opacity-70 hover:opacity-100 transition-opacity"> <span class="opacity-70 hover:opacity-100 transition-opacity">
&#9654; {$t.common?.open || 'Open'} &#9654; {_t.common?.open || 'Open'}
</span> </span>
</div> </div>
{/if} {/if}

View File

@@ -33,7 +33,7 @@
@RATIONALE $effect(() => store.subscribe(s => state = s)) creates exactly ONE @RATIONALE $effect(() => store.subscribe(s => state = s)) creates exactly ONE
subscription per component lifecycle — no accumulating render_effects. --> subscription per component lifecycle — no accumulating render_effects. -->
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n/index.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js';
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
import { cancelRun, downloadFailedCsv } from '$lib/api/translate.js'; import { cancelRun, downloadFailedCsv } from '$lib/api/translate.js';
import { translationRunStore } from '$lib/stores/translationRun.svelte.js'; import { translationRunStore } from '$lib/stores/translationRun.svelte.js';
@@ -42,6 +42,9 @@
let { runId, onRetry = () => {}, onRetryInsert = () => {}, onComplete = () => {} } = $props(); let { runId, onRetry = () => {}, onRetryInsert = () => {}, onComplete = () => {} } = $props();
let isCancelling = $state(false); let isCancelling = $state(false);
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
/** Non-reactive guard — prevents double-firing of onComplete. /** Non-reactive guard — prevents double-firing of onComplete.
* MUST NOT be $state: the $effect reads AND writes it, and a reactive * MUST NOT be $state: the $effect reads AND writes it, and a reactive
* write-after-read inside $effect triggers an infinite Svelte flush loop. */ * write-after-read inside $effect triggers an infinite Svelte flush loop. */
@@ -86,9 +89,9 @@
isCancelling = true; isCancelling = true;
try { try {
await cancelRun(runId); await cancelRun(runId);
addToast($t.translate?.run?.run_cancelled, 'info'); addToast(getT()?.translate?.run?.run_cancelled, 'info');
} catch (err) { } catch (err) {
addToast(err?.message || $t.translate?.run?.cancel_failed, 'error'); addToast(err?.message || getT()?.translate?.run?.cancel_failed, 'error');
} finally { } finally {
isCancelling = false; isCancelling = false;
} }
@@ -98,7 +101,7 @@
<div class="translation-run-progress"> <div class="translation-run-progress">
{#if uxState === 'idle'} {#if uxState === 'idle'}
<div class="bg-surface-muted border border-border rounded-lg p-4 text-center"> <div class="bg-surface-muted border border-border rounded-lg p-4 text-center">
<p class="text-sm text-text-muted">{$t.translate?.run?.loading}</p> <p class="text-sm text-text-muted">{_t.translate?.run?.loading}</p>
</div> </div>
{:else if isRunning} {:else if isRunning}
@@ -108,7 +111,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-ring" /> <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-ring" />
<span class="text-sm font-medium text-text"> <span class="text-sm font-medium text-text">
{uxState === 'inserting' ? $t.translate?.run?.insert_phase : $t.translate?.run?.translate_phase} {uxState === 'inserting' ? _t.translate?.run?.insert_phase : _t.translate?.run?.translate_phase}
</span> </span>
</div> </div>
<button <button
@@ -116,7 +119,7 @@
disabled={isCancelling} disabled={isCancelling}
class="px-3 py-1 text-xs border border-destructive-ring text-destructive rounded hover:bg-destructive-light disabled:opacity-50 transition-colors" class="px-3 py-1 text-xs border border-destructive-ring text-destructive rounded hover:bg-destructive-light disabled:opacity-50 transition-colors"
> >
{isCancelling ? $t.translate?.run?.cancelling : $t.translate?.run?.cancel} {isCancelling ? _t.translate?.run?.cancelling : _t.translate?.run?.cancel}
</button> </button>
</div> </div>
@@ -131,19 +134,19 @@
<!-- Statistics --> <!-- Statistics -->
<div class="grid grid-cols-5 gap-3 text-sm"> <div class="grid grid-cols-5 gap-3 text-sm">
<div> <div>
<span class="text-text-muted">{$t.translate?.run?.total}</span> <span class="text-text-muted">{_t.translate?.run?.total}</span>
<p class="font-semibold">{totalRecords}</p> <p class="font-semibold">{totalRecords}</p>
</div> </div>
<div> <div>
<span class="text-success">{$t.translate?.run?.success}</span> <span class="text-success">{_t.translate?.run?.success}</span>
<p class="font-semibold text-success">{successfulRecords}</p> <p class="font-semibold text-success">{successfulRecords}</p>
</div> </div>
<div> <div>
<span class="text-destructive">{$t.translate?.run?.failed}</span> <span class="text-destructive">{_t.translate?.run?.failed}</span>
<p class="font-semibold text-destructive">{failedRecords}</p> <p class="font-semibold text-destructive">{failedRecords}</p>
</div> </div>
<div> <div>
<span class="text-warning">{$t.translate?.run?.skipped}</span> <span class="text-warning">{_t.translate?.run?.skipped}</span>
<p class="font-semibold text-warning">{skippedRecords}</p> <p class="font-semibold text-warning">{skippedRecords}</p>
</div> </div>
<div> <div>
@@ -154,7 +157,7 @@
<!-- Batch counter --> <!-- Batch counter -->
<p class="text-xs text-text-subtle"> <p class="text-xs text-text-subtle">
{$t.translate?.run?.batches_progress?.replace('{count}', batchCount).replace('{pct}', progressPct)} {_t.translate?.run?.batches_progress?.replace('{count}', batchCount).replace('{pct}', progressPct)}
</p> </p>
</div> </div>
@@ -163,20 +166,20 @@
<div class="bg-success-light border border-success rounded-lg p-4"> <div class="bg-success-light border border-success rounded-lg p-4">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<span class="text-success text-lg">&#10003;</span> <span class="text-success text-lg">&#10003;</span>
<span class="text-sm font-semibold text-success">{$t.translate?.run?.completed}</span> <span class="text-sm font-semibold text-success">{_t.translate?.run?.completed}</span>
</div> </div>
<div class="grid grid-cols-3 gap-3 text-sm"> <div class="grid grid-cols-3 gap-3 text-sm">
<div> <div>
<span class="text-text-muted">{$t.translate?.run?.total}</span> <span class="text-text-muted">{_t.translate?.run?.total}</span>
<p class="font-semibold text-success">{successfulRecords} / {totalRecords}</p> <p class="font-semibold text-success">{successfulRecords} / {totalRecords}</p>
</div> </div>
<div> <div>
<span class="text-text-muted">{$t.translate?.run?.skipped}</span> <span class="text-text-muted">{_t.translate?.run?.skipped}</span>
<p class="font-semibold">{skippedRecords}</p> <p class="font-semibold">{skippedRecords}</p>
</div> </div>
<div> <div>
<span class="text-text-muted">{$t.translate?.run?.insert_status?.replace(':', '')}</span> <span class="text-text-muted">{_t.translate?.run?.insert_status?.replace(':', '')}</span>
<p class="font-semibold text-success">{insertStatus || $t.translate?.run?.insert_completed}</p> <p class="font-semibold text-success">{insertStatus || _t.translate?.run?.insert_completed}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -187,26 +190,26 @@
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-warning text-lg">&#9888;</span> <span class="text-warning text-lg">&#9888;</span>
<span class="text-sm font-semibold text-warning">{$t.translate?.run?.completed_with_errors}</span> <span class="text-sm font-semibold text-warning">{_t.translate?.run?.completed_with_errors}</span>
</div> </div>
<button <button
onclick={onRetry} onclick={onRetry}
class="px-3 py-1 text-xs bg-warning-light text-warning rounded hover:bg-yellow-200 transition-colors" class="px-3 py-1 text-xs bg-warning-light text-warning rounded hover:bg-yellow-200 transition-colors"
> >
{$t.translate?.run?.retry_failed} {_t.translate?.run?.retry_failed}
</button> </button>
</div> </div>
<div class="grid grid-cols-3 gap-3 text-sm"> <div class="grid grid-cols-3 gap-3 text-sm">
<div> <div>
<span class="text-text-muted">{$t.translate?.run?.success}</span> <span class="text-text-muted">{_t.translate?.run?.success}</span>
<p class="font-semibold text-success">{successfulRecords}</p> <p class="font-semibold text-success">{successfulRecords}</p>
</div> </div>
<div> <div>
<span class="text-text-muted">{$t.translate?.run?.failed}</span> <span class="text-text-muted">{_t.translate?.run?.failed}</span>
<p class="font-semibold text-destructive">{failedRecords}</p> <p class="font-semibold text-destructive">{failedRecords}</p>
</div> </div>
<div> <div>
<span class="text-text-muted">{$t.translate?.run?.skipped}</span> <span class="text-text-muted">{_t.translate?.run?.skipped}</span>
<p class="font-semibold">{skippedRecords}</p> <p class="font-semibold">{skippedRecords}</p>
</div> </div>
</div> </div>
@@ -218,16 +221,16 @@
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-destructive text-lg">&#10007;</span> <span class="text-destructive text-lg">&#10007;</span>
<span class="text-sm font-semibold text-destructive">{$t.translate?.run?.translation_failed}</span> <span class="text-sm font-semibold text-destructive">{_t.translate?.run?.translation_failed}</span>
</div> </div>
<button <button
onclick={onRetry} onclick={onRetry}
class="px-3 py-1 text-xs bg-destructive-light text-destructive rounded hover:bg-destructive-light transition-colors" class="px-3 py-1 text-xs bg-destructive-light text-destructive rounded hover:bg-destructive-light transition-colors"
> >
{$t.translate?.common?.retry} {_t.translate?.common?.retry}
</button> </button>
</div> </div>
<p class="text-xs text-destructive">{status?.error_message || $t.translate?.common?.unknown}</p> <p class="text-xs text-destructive">{status?.error_message || _t.translate?.common?.unknown}</p>
</div> </div>
<!-- Insert Failed --> <!-- Insert Failed -->
@@ -236,7 +239,7 @@
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-warning text-lg">&#9888;</span> <span class="text-warning text-lg">&#9888;</span>
<span class="text-sm font-semibold text-warning">{$t.translate?.run?.insert_failed}</span> <span class="text-sm font-semibold text-warning">{_t.translate?.run?.insert_failed}</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
@@ -246,18 +249,18 @@
}} }}
class="px-3 py-1 text-xs bg-primary-light text-primary rounded hover:bg-primary-light transition-colors" class="px-3 py-1 text-xs bg-primary-light text-primary rounded hover:bg-primary-light transition-colors"
> >
{$t.translate?.run?.download_csv || 'Download CSV'} {_t.translate?.run?.download_csv || 'Download CSV'}
</button> </button>
<button <button
onclick={onRetryInsert} onclick={onRetryInsert}
class="px-3 py-1 text-xs bg-warning-light text-warning rounded hover:bg-warning-light transition-colors" class="px-3 py-1 text-xs bg-warning-light text-warning rounded hover:bg-warning-light transition-colors"
> >
{$t.translate?.run?.retry_insert} {_t.translate?.run?.retry_insert}
</button> </button>
</div> </div>
</div> </div>
<p class="text-xs text-text-muted mt-1"> <p class="text-xs text-text-muted mt-1">
{$t.translate?.run?.completed} ({successfulRecords}) &mdash; {$t.translate?.run?.insert_failed} {_t.translate?.run?.completed} ({successfulRecords}) &mdash; {_t.translate?.run?.insert_failed}
</p> </p>
</div> </div>
@@ -266,7 +269,7 @@
<div class="bg-surface-muted border border-border rounded-lg p-4"> <div class="bg-surface-muted border border-border rounded-lg p-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-text-muted text-lg">&#9632;</span> <span class="text-text-muted text-lg">&#9632;</span>
<span class="text-sm font-semibold text-text">{$t.translate?.run?.cancelled}</span> <span class="text-sm font-semibold text-text">{_t.translate?.run?.cancelled}</span>
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -9,7 +9,7 @@
<!-- @UX_FEEDBACK Tabular statistics; click to copy Superset query ID; collapsible SQL block. --> <!-- @UX_FEEDBACK Tabular statistics; click to copy Superset query ID; collapsible SQL block. -->
<!-- @UX_RECOVERY Retry failed batches; retry insert. --> <!-- @UX_RECOVERY Retry failed batches; retry insert. -->
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n/index.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js';
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
import { import {
fetchRunStatus, fetchRunStatus,
@@ -30,6 +30,9 @@
* @type {'completed'|'partial'|'failed'|'insert_failed'} * @type {'completed'|'partial'|'failed'|'insert_failed'}
*/ */
let uxState = $state('completed'); let uxState = $state('completed');
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
let status = $state(null); let status = $state(null);
let records = $state([]); let records = $state([]);
let batches = $state([]); let batches = $state([]);
@@ -45,11 +48,11 @@
function getRunStatusLabel(value) { function getRunStatusLabel(value) {
const labels = { const labels = {
COMPLETED: $t.translate?.run?.completed || 'Completed', COMPLETED: getT()?.translate?.run?.completed || 'Completed',
FAILED: $t.translate?.run?.translation_failed || 'Failed', FAILED: getT()?.translate?.run?.translation_failed || 'Failed',
RUNNING: $t.translate?.history?.status_running || 'Running', RUNNING: getT()?.translate?.history?.status_running || 'Running',
PENDING: $t.translate?.history?.status_pending || 'Pending', PENDING: getT()?.translate?.history?.status_pending || 'Pending',
CANCELLED: $t.translate?.run?.cancelled || 'Cancelled', CANCELLED: getT()?.translate?.run?.cancelled || 'Cancelled',
}; };
return labels[value] || value; return labels[value] || value;
} }
@@ -57,8 +60,8 @@
// Derived // Derived
let insertStatusBadge = $derived.by(() => { let insertStatusBadge = $derived.by(() => {
const s = status?.insert_status; const s = status?.insert_status;
if (!s || s === 'success') return { label: $t.translate?.run?.success, class: 'bg-success-light text-success' }; if (!s || s === 'success') return { label: getT()?.translate?.run?.success, class: 'bg-success-light text-success' };
if (s === 'failed' || s === 'timeout') return { label: $t.translate?.run?.failed, class: 'bg-destructive-light text-destructive' }; if (s === 'failed' || s === 'timeout') return { label: getT()?.translate?.run?.failed, class: 'bg-destructive-light text-destructive' };
return { label: s, class: 'bg-warning-light text-warning' }; return { label: s, class: 'bg-warning-light text-warning' };
}); });
@@ -109,7 +112,7 @@
uxState = 'completed'; uxState = 'completed';
} }
} catch (err) { } catch (err) {
addToast(err?.message || $t.translate?.run?.load_failed, 'error'); addToast(err?.message || getT()?.translate?.run?.load_failed, 'error');
} }
} }
@@ -118,11 +121,11 @@
isRetrying = true; isRetrying = true;
try { try {
await retryFailedBatches(runId); await retryFailedBatches(runId);
addToast($t.translate?.run?.retry_success, 'success'); addToast(getT()?.translate?.run?.retry_success, 'success');
await loadData(); await loadData();
onRefresh(); onRefresh();
} catch (err) { } catch (err) {
addToast(err?.message || $t.translate?.run?.retry_failed_msg, 'error'); addToast(err?.message || getT()?.translate?.run?.retry_failed_msg, 'error');
} finally { } finally {
isRetrying = false; isRetrying = false;
} }
@@ -133,11 +136,11 @@
isRetryingInsert = true; isRetryingInsert = true;
try { try {
await retryInsert(runId); await retryInsert(runId);
addToast($t.translate?.run?.insert_retry_success, 'success'); addToast(getT()?.translate?.run?.insert_retry_success, 'success');
await loadData(); await loadData();
onRefresh(); onRefresh();
} catch (err) { } catch (err) {
addToast(err?.message || $t.translate?.run?.insert_retry_failed_msg, 'error'); addToast(err?.message || getT()?.translate?.run?.insert_retry_failed_msg, 'error');
} finally { } finally {
isRetryingInsert = false; isRetryingInsert = false;
} }
@@ -146,9 +149,9 @@
/** @param {string} text */ /** @param {string} text */
function copyToClipboard(text) { function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
addToast($t.translate?.run?.copy_success, 'success'); addToast(getT()?.translate?.run?.copy_success, 'success');
}).catch(() => { }).catch(() => {
addToast($t.translate?.run?.copy_failed, 'error'); addToast(getT()?.translate?.run?.copy_failed, 'error');
}); });
} }
@@ -162,7 +165,7 @@
const items = allRecords?.items || []; const items = allRecords?.items || [];
sqlAuditLines = items.slice(0, 20).map(r => r.target_sql).filter(Boolean); sqlAuditLines = items.slice(0, 20).map(r => r.target_sql).filter(Boolean);
} catch (err) { } catch (err) {
sqlAuditLines = [$t.translate?.run?.no_sql_audit]; sqlAuditLines = [getT()?.translate?.run?.no_sql_audit];
} }
} }
} }
@@ -171,13 +174,13 @@
<div class="translation-run-result"> <div class="translation-run-result">
{#if !status} {#if !status}
<div class="bg-surface-muted border border-border rounded-lg p-4 text-center"> <div class="bg-surface-muted border border-border rounded-lg p-4 text-center">
<p class="text-sm text-text-muted">{$t.translate?.run?.loading_result}</p> <p class="text-sm text-text-muted">{_t.translate?.run?.loading_result}</p>
</div> </div>
{:else} {:else}
<div class="bg-surface-card border border-border rounded-lg p-4 space-y-4"> <div class="bg-surface-card border border-border rounded-lg p-4 space-y-4">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-text">{$t.translate?.run?.result_title}</h3> <h3 class="text-lg font-semibold text-text">{_t.translate?.run?.result_title}</h3>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Download CSV for failed/insert_failed runs with successful records --> <!-- Download CSV for failed/insert_failed runs with successful records -->
{#if (uxState === 'insert_failed' || uxState === 'failed' || uxState === 'partial') && (status.successful_records || 0) > 0} {#if (uxState === 'insert_failed' || uxState === 'failed' || uxState === 'partial') && (status.successful_records || 0) > 0}
@@ -188,7 +191,7 @@
}} }}
class="px-3 py-1.5 text-xs bg-primary-light text-primary rounded hover:bg-primary-light transition-colors" class="px-3 py-1.5 text-xs bg-primary-light text-primary rounded hover:bg-primary-light transition-colors"
> >
{$t.translate?.run?.download_csv || 'CSV'} {_t.translate?.run?.download_csv || 'CSV'}
</button> </button>
{/if} {/if}
<!-- Retry buttons --> <!-- Retry buttons -->
@@ -198,7 +201,7 @@
disabled={isRetrying} disabled={isRetrying}
class="px-3 py-1.5 text-xs bg-primary text-white rounded hover:bg-primary-hover disabled:opacity-50 transition-colors" class="px-3 py-1.5 text-xs bg-primary text-white rounded hover:bg-primary-hover disabled:opacity-50 transition-colors"
> >
{isRetrying ? $t.translate?.run?.retrying : $t.translate?.run?.retry_failed} {isRetrying ? _t.translate?.run?.retrying : _t.translate?.run?.retry_failed}
</button> </button>
{/if} {/if}
{#if uxState === 'insert_failed'} {#if uxState === 'insert_failed'}
@@ -207,7 +210,7 @@
disabled={isRetryingInsert} disabled={isRetryingInsert}
class="px-3 py-1.5 text-xs bg-warning-light text-white rounded hover:bg-warning-light disabled:opacity-50 transition-colors" class="px-3 py-1.5 text-xs bg-warning-light text-white rounded hover:bg-warning-light disabled:opacity-50 transition-colors"
> >
{isRetryingInsert ? $t.translate?.run?.retrying_insert : $t.translate?.run?.retry_insert} {isRetryingInsert ? _t.translate?.run?.retrying_insert : _t.translate?.run?.retry_insert}
</button> </button>
{/if} {/if}
</div> </div>
@@ -216,19 +219,19 @@
<!-- Statistics Grid --> <!-- Statistics Grid -->
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4"> <div class="grid grid-cols-2 sm:grid-cols-5 gap-4">
<div class="bg-surface-muted rounded-lg p-3"> <div class="bg-surface-muted rounded-lg p-3">
<p class="text-xs text-text-muted uppercase">{$t.translate?.run?.total_records}</p> <p class="text-xs text-text-muted uppercase">{_t.translate?.run?.total_records}</p>
<p class="text-xl font-bold text-text">{status.total_records || 0}</p> <p class="text-xl font-bold text-text">{status.total_records || 0}</p>
</div> </div>
<div class="bg-success-light rounded-lg p-3"> <div class="bg-success-light rounded-lg p-3">
<p class="text-xs text-success uppercase">{$t.translate?.run?.success}</p> <p class="text-xs text-success uppercase">{_t.translate?.run?.success}</p>
<p class="text-xl font-bold text-success">{status.successful_records || 0}</p> <p class="text-xl font-bold text-success">{status.successful_records || 0}</p>
</div> </div>
<div class="bg-destructive-light rounded-lg p-3"> <div class="bg-destructive-light rounded-lg p-3">
<p class="text-xs text-destructive uppercase">{$t.translate?.run?.failed}</p> <p class="text-xs text-destructive uppercase">{_t.translate?.run?.failed}</p>
<p class="text-xl font-bold text-destructive">{status.failed_records || 0}</p> <p class="text-xl font-bold text-destructive">{status.failed_records || 0}</p>
</div> </div>
<div class="bg-warning-light rounded-lg p-3"> <div class="bg-warning-light rounded-lg p-3">
<p class="text-xs text-warning uppercase">{$t.translate?.run?.skipped}</p> <p class="text-xs text-warning uppercase">{_t.translate?.run?.skipped}</p>
<p class="text-xl font-bold text-warning">{status.skipped_records || 0}</p> <p class="text-xl font-bold text-warning">{status.skipped_records || 0}</p>
</div> </div>
<div class="bg-info-light rounded-lg p-3"> <div class="bg-info-light rounded-lg p-3">
@@ -240,7 +243,7 @@
<!-- Per-language Statistics --> <!-- Per-language Statistics -->
{#if status.language_stats && status.language_stats.length > 0} {#if status.language_stats && status.language_stats.length > 0}
<div class="border-t border-border pt-3"> <div class="border-t border-border pt-3">
<p class="text-xs text-text-muted uppercase mb-2">{$t.translate?.run?.per_language ?? 'Per-Language Statistics'}</p> <p class="text-xs text-text-muted uppercase mb-2">{_t.translate?.run?.per_language ?? 'Per-Language Statistics'}</p>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
{#each status.language_stats as lang} {#each status.language_stats as lang}
<div class="px-3 py-2 bg-surface-muted rounded border border-border min-w-[140px]"> <div class="px-3 py-2 bg-surface-muted rounded border border-border min-w-[140px]">
@@ -265,42 +268,42 @@
<div class="border-t border-border pt-3"> <div class="border-t border-border pt-3">
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span class="text-text-muted">{$t.translate?.run?.run_id}</span> <span class="text-text-muted">{_t.translate?.run?.run_id}</span>
<span class="ml-1 font-mono text-xs text-text">{status.id}</span> <span class="ml-1 font-mono text-xs text-text">{status.id}</span>
<button <button
onclick={() => copyToClipboard(status.id)} onclick={() => copyToClipboard(status.id)}
class="ml-1 text-primary hover:text-primary text-xs" class="ml-1 text-primary hover:text-primary text-xs"
title={$t.translate?.run?.copy_id} title={_t.translate?.run?.copy_id}
> >
{$t.translate?.run?.copy_id} {_t.translate?.run?.copy_id}
</button> </button>
</div> </div>
<div> <div>
<span class="text-text-muted">{$t.translate?.run?.status_label || 'Статус'}</span> <span class="text-text-muted">{_t.translate?.run?.status_label || 'Статус'}</span>
<span class="ml-1 text-xs font-medium text-text">{getRunStatusLabel(status.status)}</span> <span class="ml-1 text-xs font-medium text-text">{getRunStatusLabel(status.status)}</span>
</div> </div>
<div> <div>
<span class="text-text-muted">{$t.translate?.run?.insert_status}</span> <span class="text-text-muted">{_t.translate?.run?.insert_status}</span>
<span class="ml-1 inline-flex px-2 py-0.5 text-xs rounded-full font-medium {insertStatusBadge.class}"> <span class="ml-1 inline-flex px-2 py-0.5 text-xs rounded-full font-medium {insertStatusBadge.class}">
{insertStatusBadge.label} {insertStatusBadge.label}
</span> </span>
</div> </div>
{#if status.superset_execution_id} {#if status.superset_execution_id}
<div> <div>
<span class="text-text-muted">{$t.translate?.run?.superset_query}</span> <span class="text-text-muted">{_t.translate?.run?.superset_query}</span>
<span class="ml-1 font-mono text-xs text-text">{status.superset_execution_id}</span> <span class="ml-1 font-mono text-xs text-text">{status.superset_execution_id}</span>
<button <button
onclick={() => copyToClipboard(status.superset_execution_id)} onclick={() => copyToClipboard(status.superset_execution_id)}
class="ml-1 text-primary hover:text-primary text-xs" class="ml-1 text-primary hover:text-primary text-xs"
title={$t.translate?.run?.copy_id} title={_t.translate?.run?.copy_id}
> >
{$t.translate?.run?.copy_id} {_t.translate?.run?.copy_id}
</button> </button>
</div> </div>
{/if} {/if}
{#if status.error_message} {#if status.error_message}
<div class="col-span-2"> <div class="col-span-2">
<span class="text-destructive text-xs">{$t.translate?.run?.error_label?.replace('{error}', status.error_message)}</span> <span class="text-destructive text-xs">{_t.translate?.run?.error_label?.replace('{error}', status.error_message)}</span>
</div> </div>
{/if} {/if}
</div> </div>
@@ -318,13 +321,13 @@
> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
{$t.translate?.run?.sql_audit?.replace('{count}', sqlAuditLines.length)} {_t.translate?.run?.sql_audit?.replace('{count}', sqlAuditLines.length)}
</button> </button>
{#if showSqlAudit} {#if showSqlAudit}
<div class="mt-2 bg-terminal-bg rounded-lg p-3 max-h-60 overflow-y-auto"> <div class="mt-2 bg-terminal-bg rounded-lg p-3 max-h-60 overflow-y-auto">
{#if sqlAuditLines.length === 0} {#if sqlAuditLines.length === 0}
<p class="text-xs text-text-subtle">{$t.translate?.run?.no_sql_audit}</p> <p class="text-xs text-text-subtle">{_t.translate?.run?.no_sql_audit}</p>
{:else} {:else}
{#each sqlAuditLines as line, idx} {#each sqlAuditLines as line, idx}
<pre class="text-xs text-success mb-1 font-mono whitespace-pre-wrap break-all"> <pre class="text-xs text-success mb-1 font-mono whitespace-pre-wrap break-all">
@@ -340,14 +343,14 @@
{#if status.event_invariants} {#if status.event_invariants}
<div class="border-t border-border pt-3"> <div class="border-t border-border pt-3">
<div class="flex items-center gap-2 text-xs text-text-muted"> <div class="flex items-center gap-2 text-xs text-text-muted">
<span>{$t.translate?.run?.event_invariants}</span> <span>{_t.translate?.run?.event_invariants}</span>
<span class="{status.event_invariants.invariant_valid ? 'text-success' : 'text-destructive'}"> <span class="{status.event_invariants.invariant_valid ? 'text-success' : 'text-destructive'}">
{status.event_invariants.invariant_valid ? $t.translate?.run?.valid : $t.translate?.run?.violated} {status.event_invariants.invariant_valid ? _t.translate?.run?.valid : _t.translate?.run?.violated}
</span> </span>
<span class="text-text-subtle">|</span> <span class="text-text-subtle">|</span>
<span>{$t.translate?.run?.superset_query_id_label} {status.event_invariants.has_run_started ? $t.translate?.run?.started_yes : $t.translate?.run?.started_no}</span> <span>{_t.translate?.run?.superset_query_id_label} {status.event_invariants.has_run_started ? _t.translate?.run?.started_yes : _t.translate?.run?.started_no}</span>
<span class="text-text-subtle">|</span> <span class="text-text-subtle">|</span>
<span>{$t.translate?.run?.terminal_events?.replace('{count}', status.event_invariants.terminal_event_count)}</span> <span>{_t.translate?.run?.terminal_events?.replace('{count}', status.event_invariants.terminal_event_count)}</span>
</div> </div>
</div> </div>
{/if} {/if}
@@ -366,7 +369,7 @@
> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
{$t.translate?.run?.records_title || 'Translation Records'} ({records.length}) {_t.translate?.run?.records_title || 'Translation Records'} ({records.length})
</button> </button>
</div> </div>
@@ -376,7 +379,7 @@
<thead class="bg-surface-muted"> <thead class="bg-surface-muted">
<tr> <tr>
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase w-12">#</th> <th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase w-12">#</th>
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase min-w-[150px]">{$t.translate?.preview?.table_source || 'Source'}</th> <th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase min-w-[150px]">{_t.translate?.preview?.table_source || 'Source'}</th>
{#each targetLanguages as lang} {#each targetLanguages as lang}
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase min-w-[180px]"> <th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase min-w-[180px]">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@@ -385,10 +388,10 @@
</th> </th>
{/each} {/each}
{#if targetLanguages.length === 0} {#if targetLanguages.length === 0}
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase min-w-[180px]">{$t.translate?.preview?.translation || 'Translation'}</th> <th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase min-w-[180px]">{_t.translate?.preview?.translation || 'Translation'}</th>
{/if} {/if}
<th class="px-3 py-2 text-center text-xs font-medium text-text-muted uppercase w-20">{$t.translate?.preview?.table_status || 'Status'}</th> <th class="px-3 py-2 text-center text-xs font-medium text-text-muted uppercase w-20">{_t.translate?.preview?.table_status || 'Status'}</th>
<th class="px-3 py-2 text-center text-xs font-medium text-text-muted uppercase w-24">{$t.translate?.preview?.table_actions || 'Actions'}</th> <th class="px-3 py-2 text-center text-xs font-medium text-text-muted uppercase w-24">{_t.translate?.preview?.table_actions || 'Actions'}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-border"> <tbody class="divide-y divide-border">
@@ -450,7 +453,7 @@
class="px-2 py-0.5 text-[10px] bg-primary text-white rounded hover:bg-primary-hover transition-colors" class="px-2 py-0.5 text-[10px] bg-primary text-white rounded hover:bg-primary-hover transition-colors"
title="Submit Correction" title="Submit Correction"
> >
{$t.translate?.run?.correct || 'Correct'} {_t.translate?.run?.correct || 'Correct'}
</button> </button>
</td> </td>
</tr> </tr>

View File

@@ -13,7 +13,7 @@
import { api } from '$lib/api.js'; import { api } from '$lib/api.js';
import { log } from '$lib/cot-logger'; import { log } from '$lib/cot-logger';
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
import { t, _ } from '$lib/i18n/index.svelte.js'; import { _, getT } from '$lib/i18n/index.svelte.js';
import { triggerRun, fetchRunHistory, cancelRun } from '$lib/api/translate.js'; import { triggerRun, fetchRunHistory, cancelRun } from '$lib/api/translate.js';
import { startTranslationRun, resetTranslationRun, translationRunStore } from '$lib/stores/translationRun.svelte.js'; import { startTranslationRun, resetTranslationRun, translationRunStore } from '$lib/stores/translationRun.svelte.js';
@@ -69,13 +69,13 @@ export class TranslationJobModel {
activeTab: string = $state('config'); activeTab: string = $state('config');
tabs: { id: string; label: string }[] = $derived.by(() => { tabs: { id: string; label: string }[] = $derived.by(() => {
const base = [ const base = [
{ id: 'config', label: $t.translate?.config?.basic_info || 'Configuration' }, { id: 'config', label: getT()?.translate?.config?.basic_info || 'Configuration' },
{ id: 'preview', label: $t.translate?.preview?.title || 'Preview' }, { id: 'preview', label: getT()?.translate?.preview?.title || 'Preview' },
{ id: 'run', label: $t.translate?.config?.run_translation || 'Run' }, { id: 'run', label: getT()?.translate?.config?.run_translation || 'Run' },
]; ];
if (!this.isNewJob && this.existingJob) { if (!this.isNewJob && this.existingJob) {
base.splice(2, 0, { id: 'target', label: $t.translate?.config?.target_table_tab || 'Target Config' }); base.splice(2, 0, { id: 'target', label: getT()?.translate?.config?.target_table_tab || 'Target Config' });
base.push({ id: 'schedule', label: $t.translate?.schedule?.tab_label || 'Schedule' }); base.push({ id: 'schedule', label: getT()?.translate?.schedule?.tab_label || 'Schedule' });
} }
return base; return base;
}); });
@@ -92,9 +92,9 @@ export class TranslationJobModel {
!!this.translationColumn && !!this.datasourceId && this.targetLanguages.length > 0 && !!this.providerId !!this.translationColumn && !!this.datasourceId && this.targetLanguages.length > 0 && !!this.providerId
); );
pageTitle = $derived.by(() => { pageTitle = $derived.by(() => {
if (this.isNewJob) return $t.translate?.config?.new_title || 'New Translation Job'; if (this.isNewJob) return getT()?.translate?.config?.new_title || 'New Translation Job';
const jobName = this.existingJob?.name || this.name; const jobName = this.existingJob?.name || this.name;
const base = $t.translate?.config?.edit_title || 'Edit Translation Job'; const base = getT()?.translate?.config?.edit_title || 'Edit Translation Job';
return jobName ? `${jobName} | ${base}` : base; return jobName ? `${jobName} | ${base}` : base;
}); });
@@ -274,7 +274,7 @@ export class TranslationJobModel {
this.isNewJob = false; this.isNewJob = false;
this.existingJob = { id: resp.id, ...payload }; this.existingJob = { id: resp.id, ...payload };
} }
addToast($t.translate?.config?.saved || 'Job saved', 'success'); addToast(getT()?.translate?.config?.saved || 'Job saved', 'success');
this.uxState = 'configured'; this.uxState = 'configured';
} catch (err: unknown) { } catch (err: unknown) {
this.error = err instanceof Error ? err.message : 'Failed to save'; this.error = err instanceof Error ? err.message : 'Failed to save';

View File

@@ -27,7 +27,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { ROUTES } from '$lib/routes'; import { ROUTES } from '$lib/routes';
import { page } from '$app/state'; import { page } from '$app/state';
import { t, _ } from '$lib/i18n/index.svelte.js'; import { _, getT } from '$lib/i18n/index.svelte.js';
import { api } from '$lib/api.js'; import { api } from '$lib/api.js';
import { TranslationJobModel } from '$lib/models/TranslationJobModel.svelte'; import { TranslationJobModel } from '$lib/models/TranslationJobModel.svelte';
import MultiSelect from '$lib/components/ui/MultiSelect.svelte'; import MultiSelect from '$lib/components/ui/MultiSelect.svelte';
@@ -41,6 +41,10 @@
import RunTabContent from '$lib/components/translate/RunTabContent.svelte'; import RunTabContent from '$lib/components/translate/RunTabContent.svelte';
const m = new TranslationJobModel(); const m = new TranslationJobModel();
// @RATIONALE Svelte 5 compiler fails to detect `t` as store in deeply nested
// template blocks, generating undefined `$t`. Bypass with `getT()`
// wrapped in $derived to keep template reactive to locale changes.
const _t = $derived(getT());
$effect(() => { $effect(() => {
m.jobId = page.params.id; m.jobId = page.params.id;
@@ -76,18 +80,18 @@
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<h1 class="text-2xl font-bold text-text"> <h1 class="text-2xl font-bold text-text">
{m.isNewJob ? $t.translate?.config?.new_title : $t.translate?.config?.edit_title} {m.isNewJob ? _t.translate?.config?.new_title : _t.translate?.config?.edit_title}
</h1> </h1>
{#if !m.isNewJob && m.existingJob} {#if !m.isNewJob && m.existingJob}
<p class="text-sm text-text-muted mt-1">{$t.translate?.config?.id_label.replace('{id}', m.existingJob.id)}</p> <p class="text-sm text-text-muted mt-1">{_t.translate?.config?.id_label.replace('{id}', m.existingJob.id)}</p>
{/if} {/if}
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button onclick={() => goto(ROUTES.translate.list())} class="px-4 py-2 border border-border-strong text-text rounded-lg hover:bg-surface-muted transition-colors"> <button onclick={() => goto(ROUTES.translate.list())} class="px-4 py-2 border border-border-strong text-text rounded-lg hover:bg-surface-muted transition-colors">
{$t.translate?.config?.cancel} {_t.translate?.config?.cancel}
</button> </button>
<button onclick={() => m.saveJob()} disabled={m.isSaving} class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"> <button onclick={() => m.saveJob()} disabled={m.isSaving} class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
{m.isSaving ? $t.translate?.config?.saving : $t.translate?.config?.save} {m.isSaving ? _t.translate?.config?.saving : _t.translate?.config?.save}
</button> </button>
</div> </div>
</div> </div>
@@ -95,7 +99,7 @@
<!-- Warnings --> <!-- Warnings -->
{#if m.warnings.length > 0} {#if m.warnings.length > 0}
<div class="bg-warning-light border border-warning rounded-lg p-4 mb-6"> <div class="bg-warning-light border border-warning rounded-lg p-4 mb-6">
<h4 class="text-sm font-medium text-warning mb-2">{$t.translate?.config?.warnings_title}</h4> <h4 class="text-sm font-medium text-warning mb-2">{_t.translate?.config?.warnings_title}</h4>
<ul class="list-disc list-inside text-sm text-warning space-y-1"> <ul class="list-disc list-inside text-sm text-warning space-y-1">
{#each m.warnings as w} {#each m.warnings as w}
<li>{w}</li> <li>{w}</li>
@@ -111,14 +115,14 @@
<!-- Error --> <!-- Error -->
{:else if m.uxState === 'error'} {:else if m.uxState === 'error'}
<div class="bg-destructive-light border border-destructive-light rounded-lg p-6 text-center"> <div class="bg-destructive-light border border-destructive-light rounded-lg p-6 text-center">
<p class="text-destructive mb-3">{m.error || $t.translate?.common?.unknown_error}</p> <p class="text-destructive mb-3">{m.error || _t.translate?.common?.unknown_error}</p>
<button onclick={() => goto(ROUTES.translate.list())} class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors">{$t.translate?.config?.back_to_jobs}</button> <button onclick={() => goto(ROUTES.translate.list())} class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors">{_t.translate?.config?.back_to_jobs}</button>
</div> </div>
<!-- Datasource Unavailable --> <!-- Datasource Unavailable -->
{:else if m.uxState === 'datasource_unavailable'} {:else if m.uxState === 'datasource_unavailable'}
<div class="bg-warning-light border border-orange-200 rounded-lg p-4 mb-6"> <div class="bg-warning-light border border-orange-200 rounded-lg p-4 mb-6">
<p class="text-warning text-sm">{$t.translate?.config?.datasource_unavailable}</p> <p class="text-warning text-sm">{_t.translate?.config?.datasource_unavailable}</p>
</div> </div>
<!-- Form --> <!-- Form -->
@@ -135,7 +139,7 @@
? 'bg-surface-card text-primary border border-b-white border-border -mb-px' ? 'bg-surface-card text-primary border border-b-white border-border -mb-px'
: 'text-text-muted hover:text-text hover:bg-surface-muted'} : 'text-text-muted hover:text-text hover:bg-surface-muted'}
{(tab.id === 'preview' && (!m.configValid || m.isNewJob)) || (tab.id === 'target' && m.isNewJob) ? 'opacity-40 cursor-not-allowed' : ''}" {(tab.id === 'preview' && (!m.configValid || m.isNewJob)) || (tab.id === 'target' && m.isNewJob) ? 'opacity-40 cursor-not-allowed' : ''}"
title={tab.id === 'preview' && (!m.configValid || m.isNewJob) ? ($t.translate?.preview?.config_incomplete || 'Configuration incomplete') : tab.id === 'target' && m.isNewJob ? ($t.translate?.config?.save_job_first || 'Save the job first') : ''} title={tab.id === 'preview' && (!m.configValid || m.isNewJob) ? (_t.translate?.preview?.config_incomplete || 'Configuration incomplete') : tab.id === 'target' && m.isNewJob ? (_t.translate?.config?.save_job_first || 'Save the job first') : ''}
> >
{tab.label} {tab.label}
</button> </button>
@@ -187,15 +191,15 @@
{#if !m.isNewJob && m.existingJob} {#if !m.isNewJob && m.existingJob}
{#if !m.configValid} {#if !m.configValid}
<div class="bg-warning-light border border-warning rounded-lg p-6 text-center"> <div class="bg-warning-light border border-warning rounded-lg p-6 text-center">
<p class="text-sm text-warning font-medium mb-2">{$t.translate?.preview?.config_incomplete}</p> <p class="text-sm text-warning font-medium mb-2">{_t.translate?.preview?.config_incomplete}</p>
<p class="text-xs text-warning">{$t.translate?.preview?.config_requirements}</p> <p class="text-xs text-warning">{_t.translate?.preview?.config_requirements}</p>
</div> </div>
{:else} {:else}
<TranslationPreview jobId={m.jobId} environmentId={m.environmentId} /> <TranslationPreview jobId={m.jobId} environmentId={m.environmentId} />
{/if} {/if}
{:else} {:else}
<div class="bg-surface-muted border border-border rounded-lg p-8 text-center"> <div class="bg-surface-muted border border-border rounded-lg p-8 text-center">
<p class="text-sm text-text-muted">{$t.translate?.config?.save_job_first || 'Save the job first'}</p> <p class="text-sm text-text-muted">{_t.translate?.config?.save_job_first || 'Save the job first'}</p>
</div> </div>
{/if} {/if}
</div> </div>
@@ -222,7 +226,7 @@
/> />
{:else} {:else}
<div class="bg-surface-muted border border-border rounded-lg p-8 text-center"> <div class="bg-surface-muted border border-border rounded-lg p-8 text-center">
<p class="text-sm text-text-muted">{$t.translate?.config?.save_job_first || 'Save the job first'}</p> <p class="text-sm text-text-muted">{_t.translate?.config?.save_job_first || 'Save the job first'}</p>
</div> </div>
{/if} {/if}
</div> </div>
@@ -251,7 +255,7 @@
/> />
{:else} {:else}
<div class="bg-surface-muted border border-border rounded-lg p-8 text-center"> <div class="bg-surface-muted border border-border rounded-lg p-8 text-center">
<p class="text-sm text-text-muted">{$t.translate?.config?.save_job_first || 'Save the job first'}</p> <p class="text-sm text-text-muted">{_t.translate?.config?.save_job_first || 'Save the job first'}</p>
</div> </div>
{/if} {/if}
</div> </div>