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
346 lines
15 KiB
Svelte
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 -->
|