Files
ss-tools/frontend/src/lib/components/translate/TermCorrectionPopup.svelte
busya f6cbca2214 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
2026-06-04 16:17:14 +03:00

346 lines
15 KiB
Svelte

<!-- #region TermCorrectionPopup [C:3] [TYPE Component] [SEMANTICS translate, correction, popup, dictionary, term] -->
<!-- @BRIEF Component component: lib/components/translate/TermCorrectionPopup.svelte -->
<!-- @RELATION DEPENDS_ON -> [TranslateApi] -->
<!-- @RELATION BINDS_TO -> [EXT:frontend:dictionaryApi] -->
<!-- @UX_STATE closed -> Hidden -->
<!-- @UX_STATE selecting -> User selects target dictionary -->
<!-- @UX_STATE editing -> User enters corrected term -->
<!-- @UX_STATE submitting -> API call in progress -->
<!-- @UX_STATE conflict_detected -> Existing entry found, user chooses overwrite/keep -->
<!-- @UX_STATE submitted -> Success feedback -->
<!-- @UX_FEEDBACK Toast on success/error; conflict dialog with action buttons -->
<!-- @UX_RECOVERY Cancel button returns to previous state -->
<script lang="ts">
import { addToast } from '$lib/toasts.svelte.js';
import { submitCorrection, dictionaryApi } from '$lib/api/translate.js';
import { _, getT } from '$lib/i18n/index.svelte.js';
let {
sourceTerm = '',
incorrectTarget = '',
sourceLanguage = '',
targetLanguage = '',
runId = '',
rowKey = '',
onClose = () => {},
onSubmitted = () => {},
/** @type {Record<string, any>|null} initial context data auto-captured from source row */
initialContextData = null,
/** @type {string|null} optional context usage notes */
initialUsageNotes = ''
} = $props();
/** @type {'closed'|'selecting'|'editing'|'submitting'|'conflict_detected'|'submitted'} */
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 dictionaries = $state([]);
let selectedDictId = $state('');
let conflictInfo = $state(null);
let submitResult = $state(null);
// Context state
let contextData = $state(null);
let contextUsageNotes = $state('');
let hasEditedContext = $state(false);
let showContextEditor = $state(false);
$effect(() => {
if (initialContextData) {
contextData = JSON.parse(JSON.stringify(initialContextData));
}
if (initialUsageNotes) {
contextUsageNotes = initialUsageNotes;
}
});
$effect(() => {
if (sourceTerm && incorrectTarget) {
uxState = 'selecting';
loadDictionaries();
}
});
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;
}
uxState = dictionaries.length > 0 ? 'editing' : 'selecting';
} catch {
addToast(_('translate.term_correction.dict_load_failed'), 'error');
uxState = 'editing';
}
}
async function handleSubmit() {
if (!selectedDictId || !correctedTarget.trim()) return;
uxState = 'submitting';
try {
const payload = {
source_term: sourceTerm,
incorrect_target_term: incorrectTarget,
corrected_target_term: correctedTarget.trim(),
dictionary_id: selectedDictId,
origin_run_id: runId || undefined,
origin_row_key: rowKey || undefined,
};
if (contextData) {
payload.context_data = contextData;
}
if (contextUsageNotes) {
payload.usage_notes = contextUsageNotes;
}
const result = await submitCorrection(payload);
if (result.action === 'conflict_detected') {
conflictInfo = result.conflict;
uxState = 'conflict_detected';
} else if (result.action === 'created' || result.action === 'updated') {
submitResult = result;
uxState = 'submitted';
addToast(result.message || _('translate.term_correction.submit_success'), 'success');
onSubmitted(result);
}
} catch (err) {
addToast(err?.message || _('translate.term_correction.submit_failed'), 'error');
uxState = 'editing';
}
}
async function handleConflictOverwrite() {
try {
const result = await submitCorrection({
source_term: sourceTerm,
incorrect_target_term: incorrectTarget,
corrected_target_term: correctedTarget.trim(),
dictionary_id: selectedDictId,
origin_run_id: runId || undefined,
origin_row_key: rowKey || undefined,
});
submitResult = result;
uxState = 'submitted';
addToast(result.message || _('translate.term_correction.overwrite_success'), 'success');
onSubmitted(result);
} catch (err) {
addToast(err?.message || _('translate.term_correction.overwrite_failed'), 'error');
uxState = 'conflict_detected';
}
}
function handleConflictKeep() {
uxState = 'submitted';
addToast(_('translate.term_correction.keep_success'), 'info');
onSubmitted({ action: 'kept' });
}
function handleClose() {
uxState = 'closed';
onClose();
}
</script>
{#if uxState !== 'closed'}
<!-- 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" 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>
</button>
</div>
<!-- Selecting state -->
{#if uxState === 'selecting'}
<p class="text-sm text-text-muted">{_t.translate?.term_correction?.loading_dictionaries}</p>
<!-- Editing state -->
{:else if uxState === 'editing'}
<div class="space-y-4">
{#if sourceLanguage || targetLanguage}
<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 || _t.translate?.term_correction?.detected}
</div>
{/if}
<!-- Context section (collapsible) -->
<div class="border border-border rounded-lg overflow-hidden">
<button
onclick={() => { showContextEditor = !showContextEditor; }}
class="w-full flex items-center justify-between px-3 py-2 text-xs font-medium text-text-muted bg-surface-muted hover:bg-surface-muted transition-colors"
>
<span class="flex items-center gap-1.5">
<span>📋</span>
<span>{_t.translate?.term_correction?.context_from_source}</span>
{#if contextData}
<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-success-light:text-success={hasEditedContext}
>
{hasEditedContext ? '✏️ ' + _t.translate?.term_correction?.edited_badge : '🤖 ' + _t.translate?.term_correction?.auto_badge}
</span>
{/if}
</span>
<svg class="w-3.5 h-3.5 transition-transform" class:rotate-180={showContextEditor} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if showContextEditor}
<div class="p-3 space-y-2 border-t border-border">
{#if contextData}
<div class="bg-surface-muted rounded p-2 font-mono text-[11px] text-text-muted max-h-24 overflow-y-auto">
{JSON.stringify(contextData, null, 2)}
</div>
<div class="flex gap-2">
<button
onclick={() => {
showContextEditor = true;
}}
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}
</button>
<button
onclick={() => {
contextData = null;
hasEditedContext = true;
}}
class="text-[11px] px-2 py-1 rounded border border-destructive-light text-destructive hover:bg-destructive-light"
>
{_t.translate?.term_correction?.remove_context}
</button>
</div>
{:else}
<p class="text-[11px] text-text-subtle italic">{_t.translate?.term_correction?.no_context}</p>
<button
onclick={() => {
contextData = JSON.parse(JSON.stringify(initialContextData));
hasEditedContext = false;
}}
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}
</button>
{/if}
</div>
{/if}
</div>
<!-- Usage notes -->
<div>
<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"
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>
</div>
<div>
<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" 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" 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" 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" 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}
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>
<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"
>
<option value="">{_t.translate?.term_correction?.select_dictionary}</option>
{#each dictionaries as dict}
<option value={dict.id}>{dict.name}</option>
{/each}
</select>
</div>
<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">
{_t.translate?.term_correction?.cancel}
</button>
<button
onclick={handleSubmit}
disabled={!selectedDictId || !correctedTarget.trim()}
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}
</button>
</div>
</div>
<!-- Submitting state -->
{:else if uxState === 'submitting'}
<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>
<p class="text-sm text-text-muted">{_t.translate?.term_correction?.submitting}</p>
</div>
<!-- Conflict state -->
{:else if uxState === 'conflict_detected'}
<div class="space-y-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-xs text-warning mt-1">
{_t.translate?.term_correction?.conflict_description.replace('{source}', conflictInfo?.source_term || '').replace('{existing}', conflictInfo?.existing_target_term || '')}
</p>
</div>
<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">
{_t.translate?.term_correction?.keep_existing}
</button>
<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}
</button>
</div>
</div>
<!-- Submitted state -->
{:else if uxState === 'submitted'}
<div class="text-center py-6">
<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" />
</svg>
<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>
<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}
</button>
</div>
{/if}
</div>
</div>
{/if}
<!-- #endregion TermCorrectionPopup -->