feat(frontend): update translate components + BackupManager

Translate components:
- BulkReplaceModal: add dictionary selection dropdown to save replacements
  directly to a dictionary after applying bulk find-replace
- CorrectionCell: improve inline edit UX with better state handling
- TermCorrectionPopup: enhanced popup for term corrections
- ConfigTabForm: update form field bindings
- RunTabContent: minor layout adjustments
- ScheduleConfig: improved schedule configuration UI
- TranslationPreview, TranslationRunGlobalIndicator, TranslationRunProgress:
  UX polish and state management improvements

BackupManager:
- Add AbortSignal.timeout(30s) to prevent infinite loading state
- Add onDestroy AbortController cleanup to prevent stale state
- Add error toasts for failure states (was missing — state hung forever)
- Import API_REQUEST_TIMEOUT from api.ts
This commit is contained in:
2026-06-04 16:17:14 +03:00
parent 26dfde81a5
commit f6cbca2214
10 changed files with 158 additions and 62 deletions

View File

@@ -9,13 +9,23 @@
@RELATION USES -> [EXT:frontend:api]
@INVARIANT: Only one backup task can be triggered at a time from the UI.
@UX_FEEDBACK Error toast on backup creation failure (was missing — caught state hung forever).
@UX_FEEDBACK Error toast on data load failure (was missing — loading spinner stayed forever).
@UX_FEEDBACK Error toast on schedule update failure (was missing).
@UX_RECOVERY Request timeout (30s) via AbortSignal.timeout — prevents infinite spinner when backend is unreachable.
@UX_RECOVERY In-flight requests cancelled via AbortController onDestroy — prevents stale state after navigation.
@RATIONALE Fetch API has no default timeout. Without timeout, loadData() and handleCreateBackup() hang
indefinitely when backend is unreachable, leaving loading/creating=true forever. Added 30s
timeout via AbortSignal.timeout() + onDestroy cleanup + user-facing error toasts.
@REJECTED AbortController without timeout rejected — timeout is essential; abort-on-destroy alone
doesn't protect against backend that never responds.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n/index.svelte.js';
import { api, requestApi } from '$lib/api';
import { api, requestApi, API_REQUEST_TIMEOUT } from '$lib/api';
import { addToast } from '$lib/toasts.svelte.js';
import { Button, Card, Select, Input } from '$lib/ui';
import BackupList from './BackupList.svelte';
@@ -44,6 +54,14 @@
cronExpression =
selectedEnv.backup_schedule?.cron_expression ?? '0 0 * * *';
});
// ── Lifecycle & request cancellation ───────────────────────────
let abortController = new AbortController();
onDestroy(() => {
abortController.abort();
abortController = new AbortController(); // allow re-creation if needed
});
// [/SECTION]
// #region loadData:Function [TYPE Function]
@@ -78,9 +96,11 @@
? `/storage/files?category=backups&path=${encodeURIComponent(subpath)}`
: '/storage/files?category=backups';
const signal = AbortSignal.timeout(API_REQUEST_TIMEOUT);
const [envsData, storageData] = await Promise.all([
api.getEnvironmentsList(),
requestApi(filesUrl)
api.getEnvironmentsList({ signal }),
requestApi(filesUrl, 'GET', null, { signal })
]);
environments = envsData;
// Pre-fill with active environment from global context
@@ -106,6 +126,12 @@
console.log("[EXT:frontend:BackupManager][Action] Data loaded successfully.");
} catch (error) {
console.error("[EXT:frontend:BackupManager][Coherence:Failed] Load failed", error);
if (error instanceof DOMException && error.name === 'AbortError') {
addToast('Request timed out', 'error');
} else {
const errMsg = error instanceof Error ? error.message : $t.common.error;
addToast(errMsg, 'error');
}
} finally {
loading = false;
}
@@ -140,7 +166,7 @@
cron_expression: cronExpression
});
addToast($t.common.success, 'success');
// Update local state
environments = environments.map(e =>
e.id === selectedEnvId
@@ -149,6 +175,7 @@
);
} catch (error) {
console.error("[EXT:frontend:BackupManager][Coherence:Failed] Schedule update failed", error);
addToast($t.common.error, 'error');
} finally {
savingSchedule = false;
}
@@ -164,11 +191,19 @@
console.log(`[EXT:frontend:BackupManager][Action] Triggering backup for env: ${selectedEnvId}`);
creating = true;
try {
await api.createTask('superset-backup', { environment_id: selectedEnvId });
await api.createTask('superset-backup', { environment_id: selectedEnvId },
{ signal: AbortSignal.timeout(API_REQUEST_TIMEOUT) }
);
addToast($t.common.success, 'success');
console.log("[EXT:frontend:BackupManager][Coherence:OK] Backup task triggered.");
} catch (error) {
console.error("[EXT:frontend:BackupManager][Coherence:Failed] Create failed", error);
if (error instanceof DOMException && error.name === 'AbortError') {
addToast('Backup request timed out', 'error');
} else {
const errMsg = error instanceof Error ? error.message : $t.common.error;
addToast(errMsg, 'error');
}
} finally {
creating = false;
}

View File

@@ -14,7 +14,7 @@
-->
<script lang="ts">
import { addToast } from '$lib/toasts.svelte.js';
import { bulkFindReplace, bulkReplacePreview } from '$lib/api/translate.js';
import { bulkFindReplace, bulkReplacePreview, dictionaryApi } from '$lib/api/translate.js';
import { _, getT } from '$lib/i18n/index.svelte.js';
/** @type {{ show: boolean, runId: string, targetLanguages: string[], onClose: () => void, onApplied?: (count: number) => void }} */
@@ -41,6 +41,20 @@
let errorMessage = $state('');
let submitToDict = $state(false);
let usageNotes = $state('');
let dictionaries = $state([]);
let selectedDictId = $state('');
async function loadDictionaries() {
try {
const result = await dictionaryApi.fetchDictionaries({ page_size: 100 });
dictionaries = Array.isArray(result) ? result : (result?.items || []);
if (dictionaries.length === 1) {
selectedDictId = dictionaries[0].id;
}
} catch {
addToast(_('translate.bulk_replace.dict_load_failed'), 'error');
}
}
$effect(() => {
if (show) {
@@ -54,6 +68,8 @@
errorMessage = '';
submitToDict = false;
usageNotes = '';
dictionaries = [];
selectedDictId = '';
} else {
uxState = 'closed';
}
@@ -73,12 +89,12 @@
errorMessage = '';
try {
const result = await bulkReplacePreview(runId, {
pattern: findPattern,
find_pattern: findPattern,
is_regex: isRegex,
replacement_text: replacementText,
target_language: targetLanguage
});
affectedRecords = result?.items || result?.records || [];
affectedRecords = result?.preview || [];
applyCount = affectedRecords.length;
uxState = 'previewing';
} catch (err) {
@@ -91,6 +107,7 @@
/** Open confirmation */
function handleShowConfirm() {
uxState = 'confirming';
loadDictionaries();
}
/** Cancel confirmation, back to preview */
@@ -103,15 +120,16 @@
uxState = 'applying';
try {
const result = await bulkFindReplace(runId, {
pattern: findPattern,
find_pattern: findPattern,
is_regex: isRegex,
replacement_text: replacementText,
target_language: targetLanguage,
submit_to_dictionary: submitToDict,
dictionary_id: submitToDict ? selectedDictId : undefined,
usage_notes: usageNotes || undefined,
submit_to_dictionary_with_context: submitToDict
});
const changed = result?.changed || result?.affected || applyCount;
const changed = result?.rows_affected || applyCount;
uxState = 'applied';
onApplied(changed);
addToast(_('translate.bulk_replace.applied_toast').replace('{count}', changed), 'success');
@@ -141,16 +159,21 @@
</script>
{#if show}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onclick={handleClose}
onkeydown={(e) => e.key === 'Escape' && handleClose()}
role="presentation"
>
<div
class="bg-surface-card rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[85vh] flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.key === 'Escape' && handleClose()}
role="dialog"
aria-modal="true"
aria-label={_t.translate?.bulk_replace?.title}
tabindex="-1"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
@@ -173,10 +196,11 @@
{#if uxState === 'configuring' || uxState === 'previewing' || uxState === 'preview_error'}
<!-- Find Pattern -->
<div>
<label class="block text-sm font-medium text-text mb-1">{_t.translate?.bulk_replace?.find_pattern}</label>
<label for="bulk-find-pattern" class="block text-sm font-medium text-text mb-1">{_t.translate?.bulk_replace?.find_pattern}</label>
<div class="flex gap-2">
<input
type="text"
id="bulk-find-pattern"
bind:value={findPattern}
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"
@@ -196,9 +220,10 @@
<!-- Replacement Text -->
<div>
<label class="block text-sm font-medium text-text mb-1">{_t.translate?.bulk_replace?.replace_with}</label>
<label for="bulk-replace-text" class="block text-sm font-medium text-text mb-1">{_t.translate?.bulk_replace?.replace_with}</label>
<input
type="text"
id="bulk-replace-text"
bind:value={replacementText}
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"
@@ -208,8 +233,9 @@
<!-- Target Language -->
<div>
<label class="block text-sm font-medium text-text mb-1">{_t.translate?.bulk_replace?.target_language}</label>
<label for="bulk-target-language" class="block text-sm font-medium text-text mb-1">{_t.translate?.bulk_replace?.target_language}</label>
<select
id="bulk-target-language"
bind:value={targetLanguage}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
>
@@ -264,8 +290,8 @@
{#each affectedRecords.slice(0, 100) as row, i}
<tr class="hover:bg-surface-muted">
<td class="px-3 py-2 text-text-subtle">{i + 1}</td>
<td class="px-3 py-2 text-text max-w-xs truncate">{row.final_value || row.translated_value || ''}</td>
<td class="px-3 py-2 text-success font-medium max-w-xs truncate">{previewAfter(row)}</td>
<td class="px-3 py-2 text-text max-w-xs truncate">{row.current_value || row.final_value || row.translated_value || ''}</td>
<td class="px-3 py-2 text-success font-medium max-w-xs truncate">{row.new_value || ''}</td>
</tr>
{/each}
</tbody>
@@ -308,12 +334,25 @@
<span>{_t.translate?.bulk_replace?.submit_to_dict}</span>
</label>
{#if submitToDict}
<textarea
bind:value={usageNotes}
placeholder={_t.translate?.bulk_replace?.usage_notes_placeholder}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
rows="2"
></textarea>
<div class="space-y-2 pl-6">
<label class="block text-xs font-medium text-text-muted" for="bulk-dict-select">{_t.translate?.bulk_replace?.dictionary || 'Словарь'}</label>
<select
id="bulk-dict-select"
bind:value={selectedDictId}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
>
<option value="">{_t.translate?.bulk_replace?.select_dictionary || 'Выберите словарь...'}</option>
{#each dictionaries as dict}
<option value={dict.id}>{dict.name}</option>
{/each}
</select>
<textarea
bind:value={usageNotes}
placeholder={_t.translate?.bulk_replace?.usage_notes_placeholder}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
rows="2"
></textarea>
</div>
{/if}
</div>
@@ -339,7 +378,7 @@
<!-- Applying State -->
{#if uxState === 'applying'}
<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"></div>
<span class="text-sm text-text-muted">{_t.translate?.bulk_replace?.applying}</span>
</div>
{/if}

View File

@@ -207,7 +207,7 @@
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"
placeholder={_t.translate?.config?.description_placeholder}
/>
></textarea>
</div>
</div>
</section>
@@ -275,15 +275,15 @@
</div>
{#if isColumnsLoading}
<div class="mt-4 h-8 bg-surface-muted rounded animate-pulse" />
<div class="mt-4 h-8 bg-surface-muted rounded animate-pulse"></div>
{/if}
{#if columnList.length > 0}
<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" for="available-columns">
{_t.translate?.config?.available_columns.replace('{count}', columnList.length)}
</label>
<div class="max-h-40 overflow-y-auto border border-border rounded-lg divide-y divide-border">
<div id="available-columns" class="max-h-40 overflow-y-auto border border-border rounded-lg divide-y divide-border">
{#each columnList as col}
<div class="px-3 py-1.5 text-sm flex items-center gap-2 {isVirtual(col.name) ? 'bg-warning-light' : ''}">
<span class="font-mono text-xs">{col.name}</span>
@@ -397,6 +397,7 @@
targetKeyCols = targetKeyCols.filter((_, i) => i !== idx);
}}
class="p-1 text-text-subtle hover:text-destructive"
aria-label={_t.translate?.config?.remove_key_column || 'Remove key column'}
>
<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" />

View File

@@ -34,8 +34,9 @@
// @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 displayValue = $state(value);
const initFromProp = () => value;
let editValue = $state(initFromProp());
let displayValue = $state(initFromProp());
let errorMessage = $state('');
let dictPopupOpen = $state(false);
let dictionaries = $state([]);
@@ -43,6 +44,7 @@
let editableContextData = $state(null);
let editableUsageNotes = $state('');
let contextEditMode = $state(false);
let editInput = $state(null);
$effect(() => {
// Reset if value prop changes externally
@@ -53,6 +55,13 @@
}
});
// Auto-focus edit input when entering editing mode
$effect(() => {
if (uxState === 'editing' && editInput) {
editInput.focus();
}
});
function startEditing() {
editValue = value;
uxState = 'editing';
@@ -158,9 +167,9 @@
{:else if uxState === 'editing'}
<div class="flex flex-col gap-1">
<input
bind:this={editInput}
bind:value={editValue}
class="border border-primary-ring rounded px-2 py-1 text-sm w-full focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
autofocus
onkeydown={(e) => {
if (e.key === 'Enter') saveEdit();
if (e.key === 'Escape') cancelEditing();
@@ -187,7 +196,7 @@
<!-- Saving mode -->
{:else if uxState === 'saving'}
<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"></div>
<span class="text-xs text-text-muted">{_t.translate?.corrections?.cell_saving}</span>
</div>
@@ -209,7 +218,7 @@
<!-- Submitting to dictionary mode -->
{:else if uxState === 'submitting_to_dict'}
<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"></div>
<span class="text-xs text-warning">{_t.translate?.corrections?.cell_submitting}</span>
</div>
@@ -236,11 +245,14 @@
<!-- Dictionary Selection Popup -->
{#if dictPopupOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onclick={handleCloseDictPopup}
onkeydown={(e) => e.key === 'Escape' && handleCloseDictPopup()}
role="presentation"
>
<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()} onkeydown={(e) => e.key === 'Escape' && handleCloseDictPopup()} tabindex="-1">
<h3 class="text-sm font-semibold text-text mb-4">{_t.translate?.corrections?.cell_submit_to_dict}</h3>
<div class="space-y-3">
{#if sourceLanguage}
@@ -249,12 +261,13 @@
</div>
{/if}
<div>
<label class="block text-xs font-medium text-text mb-1">{_t.translate?.corrections?.cell_corrected_value}</label>
<span class="block text-xs font-medium text-text mb-1">{_t.translate?.corrections?.cell_corrected_value}</span>
<div class="px-3 py-2 bg-surface-muted border border-border rounded text-sm">{editValue}</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" for="corr-cell-dictionary">{_t.translate?.corrections?.dictionary}</label>
<select
id="corr-cell-dictionary"
bind:value={selectedDictId}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
>
@@ -298,8 +311,9 @@
<!-- Usage Notes Section (T128) -->
<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" for="corr-cell-usage-notes">{_t.translate?.corrections?.cell_usage_notes}</label>
<textarea
id="corr-cell-usage-notes"
bind:value={editableUsageNotes}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
rows="2"

View File

@@ -257,7 +257,7 @@
<div class="bg-surface-card border border-border rounded-lg p-4">
<div class="grid grid-cols-6 gap-3">
{#each Array(6) as _}
<div class="h-16 bg-surface-muted rounded animate-pulse" />
<div class="h-16 bg-surface-muted rounded animate-pulse"></div>
{/each}
</div>
</div>

View File

@@ -182,12 +182,13 @@
<!-- Cron expression -->
<div>
<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 for="cron-expression" class="text-sm font-medium text-text">{_t.translate?.schedule?.cron_expression || 'Cron Expression'}</label>
<HelpTooltip text={_t.translate?.schedule?.help_cron || ""} />
</span>
<div class="flex gap-2">
<input
type="text"
id="cron-expression"
bind:value={cronExpression}
placeholder="0 2 * * *"
disabled={!isEditing}
@@ -213,10 +214,11 @@
<!-- Timezone -->
<div>
<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 for="timezone-select" class="text-sm font-medium text-text">{_t.translate?.schedule?.timezone || 'Timezone'}</label>
<HelpTooltip text={_t.translate?.schedule?.help_timezone || ""} />
</span>
<select
id="timezone-select"
bind:value={timezone}
disabled={!isEditing}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm disabled:bg-surface-muted disabled:text-text-muted"
@@ -230,12 +232,12 @@
<!-- Execution mode -->
<div>
<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 for="exec-mode-full" class="text-sm font-medium text-text">{_t.translate?.schedule?.execution_mode || 'Execution Mode'}</label>
<HelpTooltip text={_t.translate?.schedule?.help_execution_mode || ""} />
</span>
<div class="flex gap-4">
<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" id="exec-mode-full" 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="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>

View File

@@ -144,12 +144,13 @@
</script>
{#if uxState !== 'closed'}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick={handleClose}>
<div class="bg-surface-card rounded-lg shadow-xl p-6 w-full max-w-md mx-4" onclick={(e) => e.stopPropagation()}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation">
<div class="bg-surface-card rounded-lg shadow-xl p-6 w-full max-w-md mx-4" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.key === 'Escape' && handleClose()} tabindex="-1">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<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" aria-label={_t.translate?.term_correction?.close || 'Close'}>
<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" />
</svg>
@@ -234,10 +235,11 @@
<!-- Usage notes -->
<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" for="term-corr-usage-notes">
📝 {_t.translate?.term_correction?.usage_notes}
</label>
<textarea
id="term-corr-usage-notes"
bind:value={contextUsageNotes}
placeholder={_t.translate?.term_correction?.usage_notes_placeholder}
rows="2"
@@ -246,24 +248,25 @@
</div>
<div>
<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" />
<label class="block text-sm font-medium text-text mb-1" for="term-corr-source-term">{_t.translate?.term_correction?.source_term}</label>
<input id="term-corr-source-term" type="text" readonly value={sourceTerm} class="w-full px-3 py-2 bg-surface-muted border border-border rounded-lg text-sm" />
</div>
<div>
<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" />
<label class="block text-sm font-medium text-text mb-1" for="term-corr-incorrect-target">{_t.translate?.term_correction?.incorrect_target}</label>
<input id="term-corr-incorrect-target" type="text" readonly value={incorrectTarget} class="w-full px-3 py-2 bg-surface-muted border border-border rounded-lg text-sm" />
</div>
<div>
<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" />
<label class="block text-sm font-medium text-text mb-1" for="term-corr-source-lang">{_t.translate?.term_correction?.source_language}</label>
<input id="term-corr-source-lang" 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>
<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" />
<label class="block text-sm font-medium text-text mb-1" for="term-corr-target-lang">{_t.translate?.term_correction?.target_language}</label>
<input id="term-corr-target-lang" 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>
<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" for="term-corr-corrected-target">{_t.translate?.term_correction?.corrected_target}</label>
<input
id="term-corr-corrected-target"
type="text"
bind:value={correctedTarget}
placeholder={_t.translate?.term_correction?.corrected_placeholder}
@@ -271,8 +274,9 @@
/>
</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" for="term-corr-dictionary">{_t.translate?.term_correction?.dictionary}</label>
<select
id="term-corr-dictionary"
bind:value={selectedDictId}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
>

View File

@@ -252,12 +252,13 @@
{_t.translate?.preview?.title} &mdash; {_t.translate?.preview?.sample_size} {sampleSize}
</p>
<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 for="preview-sample-size" class="text-sm text-text-muted">{_t.translate?.preview?.sample_size}</label>
<input
type="range"
bind:value={sampleSize}
min="1"
max="100"
id="preview-sample-size"
class="w-40 accent-blue-600"
/>
<span class="text-sm font-mono text-text w-8 text-left">{sampleSize}</span>
@@ -282,12 +283,12 @@
{:else if uxState === 'loading'}
<div class="bg-surface-card border border-border rounded-lg p-6">
<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"></div>
<span class="text-sm text-text-muted">{_t.translate?.preview?.running_preview}</span>
</div>
<div class="space-y-3">
{#each Array(3) as _}
<div class="h-12 bg-surface-muted rounded animate-pulse" />
<div class="h-12 bg-surface-muted rounded animate-pulse"></div>
{/each}
</div>
</div>
@@ -435,7 +436,7 @@
<textarea
bind:value={editValues[langKey]}
class="w-full px-2 py-1 border border-primary-ring rounded text-xs focus-visible:ring-2 focus-visible:ring-primary-ring min-h-[50px]"
/>
></textarea>
<div class="flex gap-1 mt-1">
<button
onclick={() => saveLangEdit(row.id, lang)}

View File

@@ -147,7 +147,7 @@
<!-- Header row: spinner + label + actions -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-ring" />
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-ring"></div>
<span class="text-sm font-semibold text-text">{label}</span>
</div>
<div class="flex items-center gap-2">

View File

@@ -109,7 +109,7 @@
<!-- Phase indicator -->
<div class="flex items-center justify-between">
<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"></div>
<span class="text-sm font-medium text-text">
{uxState === 'inserting' ? _t.translate?.run?.insert_phase : _t.translate?.run?.translate_phase}
</span>
@@ -128,7 +128,7 @@
<div
class="bg-primary h-2.5 rounded-full transition-all duration-500"
style="width: {Math.min(progressPct, 100)}%"
/>
></div>
</div>
<!-- Statistics -->