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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -252,12 +252,13 @@
|
||||
{_t.translate?.preview?.title} — {_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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user