- Batch-fixed [ApiModule.xxx] → [xxx] in 60 @RELATION targets across 26 API files - Fixed [ToastsModule.addToast] → [addToast:Function] in notifyApiError contract - Added #region contracts for getMaintenanceEventsWsUrl and getTranslateRunWsUrl - Added @RATIONALE belief protocol to ValidationTasksListModel, DeploymentModel, MigrationModel - Semantic audit: unresolved_relation dropped 104 → 43 (-59%)
539 lines
22 KiB
Svelte
539 lines
22 KiB
Svelte
<!-- #region ConfigTabForm [C:4] [TYPE Component] [SEMANTICS translate, config, datasource, llm, columns] -->
|
|
<!-- @BRIEF Configuration tab content for Translation Job — datasource selection with auto-detected
|
|
DB dialect, column mapping, LLM provider, target languages (multi-select from settings-allowed languages),
|
|
dictionaries, disable reasoning toggle. -->
|
|
<!-- @LAYER UI -->
|
|
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:fetchDatasources] -->
|
|
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:fetchDatasourceColumns] -->
|
|
<!-- @RELATION DEPENDS_ON -> [getAllowedLanguages] -->
|
|
<!-- @RELATION DEPENDS_ON -> [MultiSelect] -->
|
|
<!-- @RELATION CALLS -> [filterLanguages]
|
|
<!-- @RATIONALE Target language list is now fetched from /settings/allowed-languages instead of static ALL_LANGUAGES.
|
|
This ensures only admin-configured languages (Settings → Languages) are shown. Falls back to full list on API error
|
|
or when allowed-languages is empty. -->
|
|
<!-- @UX_STATE Datasource searching -> Dropdown with filtered results, spinner on load -->
|
|
<!-- @UX_STATE Datasource selected -> Columns loaded, DB dialect detected, mapping ready -->
|
|
<!-- @UX_STATE Columns loading -> Skeleton pulse in column section -->
|
|
<!-- @UX_STATE Validation error -> Red border on invalid fields, inline error text -->
|
|
<!-- @UX_FEEDBACK Detected dialect badge (green pill) when datasource selected -->
|
|
<!-- @UX_FEEDBACK Spinner in datasource input while searching -->
|
|
<!-- @UX_FEEDBACK Virtual column warning (yellow bg + asterisk) -->
|
|
<!-- @UX_RECOVERY Datasource not found -> try different search term -->
|
|
<!-- @UX_RECOVERY Environment changed -> datasource search cleared, new DB list loaded -->
|
|
<!-- @UX_REACTIVITY Props -> $bindable() for all form fields, $props() for environment list -->
|
|
<!-- @UX_REACTIVITY LocalState -> datasourceList, datasourceLoading, showDatasourceDropdown -->
|
|
<script lang="ts">
|
|
import { getT } from '$lib/i18n/index.svelte.js';
|
|
import { ALL_LANGUAGES, filterLanguages } from '$lib/i18n/languages.js';
|
|
import { api } from '$lib/api.js';
|
|
import { fetchDatasources, fetchDatasourceColumns } from '$lib/api/translate.js';
|
|
import MultiSelect from '$lib/components/ui/MultiSelect.svelte';
|
|
import HelpTooltip from '$lib/ui/HelpTooltip.svelte';
|
|
|
|
// ---- Bindable form state (parent-owned) ----
|
|
let {
|
|
name = $bindable(''),
|
|
description = $bindable(''),
|
|
environmentId = $bindable(''),
|
|
environments = [],
|
|
datasourceId = $bindable(''),
|
|
datasourceSearch = $bindable(''),
|
|
databaseDialect = $bindable(''),
|
|
availableColumns = [],
|
|
virtualColumns = $bindable([]),
|
|
sourceKeyCols = $bindable([]),
|
|
targetKeyCols = $bindable([]),
|
|
translationColumn = $bindable(''),
|
|
contextColumns = $bindable([]),
|
|
targetLanguages = $bindable(['en']),
|
|
targetLanguageColumn = $bindable(''),
|
|
targetSourceColumn = $bindable(''),
|
|
targetSourceLanguageColumn = $bindable(''),
|
|
providerId = $bindable(''),
|
|
llmProviders = [],
|
|
dictionaryIds = $bindable([]),
|
|
availableDictionaries = [],
|
|
disableReasoning = $bindable(false),
|
|
validationErrors = {},
|
|
onEnvChange = () => {},
|
|
} = $props();
|
|
|
|
// ---- Local state ----
|
|
let datasourceList = $state([]);
|
|
let datasourceLoading = $state(false);
|
|
let showDatasourceDropdown = $state(false);
|
|
let isColumnsLoading = $state(false);
|
|
let columnList = $derived(availableColumns);
|
|
let allowedLanguageCodes: string[] = $state([]);
|
|
// @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());
|
|
// Language options filtered by backend-allowed codes (fallback to full list)
|
|
let languageOptions = $derived(
|
|
allowedLanguageCodes.length > 0
|
|
? filterLanguages(allowedLanguageCodes)
|
|
: ALL_LANGUAGES
|
|
);
|
|
|
|
// Load allowed languages from settings once on mount
|
|
$effect(() => {
|
|
api.getAllowedLanguages().then((codes) => {
|
|
if (Array.isArray(codes) && codes.length > 0) {
|
|
allowedLanguageCodes = codes;
|
|
}
|
|
}).catch(() => {
|
|
// Non-critical: fall back to full list
|
|
allowedLanguageCodes = [];
|
|
});
|
|
});
|
|
|
|
// ---- Derived ----
|
|
function isVirtual(colName) {
|
|
return (virtualColumns || []).includes(colName);
|
|
}
|
|
|
|
// Auto-load columns when datasourceId changes (from parent loadJob or user selection)
|
|
$effect(() => {
|
|
if (datasourceId && environmentId) {
|
|
loadColumnsForDatasource();
|
|
}
|
|
});
|
|
|
|
// ---- Datasource search ----
|
|
let searchTimer = null;
|
|
async function handleDatasourceSearch() {
|
|
clearTimeout(searchTimer);
|
|
searchTimer = setTimeout(() => searchDatasources(datasourceSearch), 300);
|
|
}
|
|
|
|
async function searchDatasources(searchTerm = '') {
|
|
if (!environmentId) return;
|
|
datasourceLoading = true;
|
|
try {
|
|
datasourceList = await fetchDatasources(environmentId, searchTerm);
|
|
showDatasourceDropdown = true;
|
|
} catch {
|
|
datasourceList = [];
|
|
} finally {
|
|
datasourceLoading = false;
|
|
}
|
|
}
|
|
|
|
/** Select a dataset from the dropdown */
|
|
function selectDatasource(ds) {
|
|
datasourceId = String(ds.id);
|
|
datasourceSearch = `${ds.table_name} (${ds.database_name} · ${ds.database_dialect})`;
|
|
showDatasourceDropdown = false;
|
|
availableColumns = [];
|
|
virtualColumns = [];
|
|
sourceKeyCols = [];
|
|
targetKeyCols = [];
|
|
translationColumn = '';
|
|
targetLanguageColumn = '';
|
|
targetSourceColumn = '';
|
|
targetSourceLanguageColumn = '';
|
|
contextColumns = [];
|
|
databaseDialect = ds.database_dialect || '';
|
|
}
|
|
|
|
async function loadColumnsForDatasource() {
|
|
if (!datasourceId || !environmentId) return;
|
|
isColumnsLoading = true;
|
|
try {
|
|
const response = await fetchDatasourceColumns(datasourceId, environmentId);
|
|
availableColumns = response.columns || response || [];
|
|
virtualColumns = response.virtual_columns || [];
|
|
} catch {
|
|
availableColumns = [];
|
|
virtualColumns = [];
|
|
} finally {
|
|
isColumnsLoading = false;
|
|
}
|
|
}
|
|
|
|
// ---- Key column helpers ----
|
|
function toggleSourceKeyCol(colName) {
|
|
if (sourceKeyCols.includes(colName)) {
|
|
const idx = sourceKeyCols.indexOf(colName);
|
|
sourceKeyCols = sourceKeyCols.filter((_, i) => i !== idx);
|
|
targetKeyCols = targetKeyCols.filter((_, i) => i !== idx);
|
|
} else {
|
|
sourceKeyCols = [...sourceKeyCols, colName];
|
|
targetKeyCols = [...targetKeyCols, colName];
|
|
}
|
|
}
|
|
|
|
function updateTargetKeyCol(idx, value) {
|
|
targetKeyCols[idx] = value;
|
|
targetKeyCols = targetKeyCols; // trigger reactivity
|
|
}
|
|
|
|
function toggleContextColumn(col) {
|
|
if (contextColumns.includes(col)) {
|
|
contextColumns = contextColumns.filter(c => c !== col);
|
|
} else {
|
|
contextColumns = [...contextColumns, col];
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Basic Info -->
|
|
<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>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="md:col-span-2">
|
|
<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>
|
|
<HelpTooltip text={_t.translate?.config?.help_name || ''} />
|
|
</label>
|
|
<input
|
|
type="text"
|
|
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"
|
|
placeholder={_t.translate?.config?.name_placeholder}
|
|
/>
|
|
{#if validationErrors.name}
|
|
<p class="text-xs text-destructive mt-1">{validationErrors.name}</p>
|
|
{/if}
|
|
</div>
|
|
<div class="md:col-span-2">
|
|
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
|
|
{_t.translate?.config?.description}
|
|
<HelpTooltip text={_t.translate?.config?.help_description || ''} />
|
|
</label>
|
|
<textarea
|
|
bind:value={description}
|
|
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}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Datasource & Environment -->
|
|
<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>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
|
|
{_t.translate?.config?.environment}
|
|
<HelpTooltip text={_t.translate?.config?.help_environment || ''} />
|
|
</label>
|
|
<select
|
|
bind:value={environmentId}
|
|
onchange={(e) => onEnvChange(e.target.value)}
|
|
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>
|
|
{#each environments as env}
|
|
<option value={env.id}>{env.name} ({env.id})</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
<div class="relative">
|
|
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
|
|
{_t.translate?.config?.datasource_id}
|
|
<HelpTooltip text={_t.translate?.config?.help_datasource || ''} />
|
|
</label>
|
|
<input
|
|
type="text"
|
|
bind:value={datasourceSearch}
|
|
oninput={handleDatasourceSearch}
|
|
onfocus={() => searchDatasources('')}
|
|
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"
|
|
placeholder={_t.translate?.config?.datasource_search_placeholder || 'Search datasets...'}
|
|
/>
|
|
{#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>
|
|
{/if}
|
|
{#if showDatasourceDropdown}
|
|
<div class="absolute z-10 mt-1 w-full bg-surface-card border border-border rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
|
{#each datasourceList as ds}
|
|
<button
|
|
type="button"
|
|
onmousedown={() => selectDatasource(ds)}
|
|
class="w-full text-left px-3 py-2 text-sm hover:bg-primary-light border-b border-border last:border-b-0"
|
|
>
|
|
<span class="font-medium">{ds.table_name}</span>
|
|
<span class="text-text-subtle ml-2">{ds.schema}</span>
|
|
<span class="float-right text-xs text-text-subtle">{ds.database_name} · {ds.database_dialect}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{#if databaseDialect}
|
|
<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">
|
|
{_t.translate?.config?.detected_dialect.replace('{dialect}', databaseDialect)}
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if isColumnsLoading}
|
|
<div class="mt-4 h-8 bg-surface-muted rounded animate-pulse" />
|
|
{/if}
|
|
|
|
{#if columnList.length > 0}
|
|
<div class="mt-4">
|
|
<label class="block text-sm font-medium text-text mb-2">
|
|
{_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">
|
|
{#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>
|
|
{#if col.type}
|
|
<span class="text-xs text-text-subtle">({col.type})</span>
|
|
{/if}
|
|
{#if isVirtual(col.name)}
|
|
<span class="text-xs text-warning italic">{_t.translate?.config?.virtual}</span>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Column Mapping -->
|
|
<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>
|
|
|
|
<!-- Translation Column -->
|
|
<div class="mb-4">
|
|
<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>
|
|
<HelpTooltip text={_t.translate?.config?.help_translation_column || ''} />
|
|
</label>
|
|
<select
|
|
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"
|
|
>
|
|
<option value="">{_t.translate?.config?.select_column}</option>
|
|
{#each columnList as col}
|
|
<option value={col.name}>{col.name}{isVirtual(col.name) ? ` (${_t.translate?.config?.virtual})` : ''}</option>
|
|
{/each}
|
|
</select>
|
|
{#if validationErrors.translationColumn}
|
|
<p class="text-xs text-destructive mt-1">{validationErrors.translationColumn}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Key Mapping (source -> target pairs) -->
|
|
<div class="mb-4">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<label class="block text-sm font-medium text-text flex items-center gap-1">
|
|
{_t.translate?.config?.key_columns}
|
|
<HelpTooltip text={_t.translate?.config?.help_key_columns || ''} />
|
|
</label>
|
|
{#if sourceKeyCols.length > 0}
|
|
<button
|
|
onclick={() => { sourceKeyCols = []; targetKeyCols = []; }}
|
|
class="text-xs text-destructive hover:text-destructive"
|
|
>
|
|
{_t.translate?.config?.clear_all}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if columnList.length === 0}
|
|
<div class="flex flex-wrap gap-2 mb-3">
|
|
<button
|
|
onclick={() => {
|
|
sourceKeyCols = [...sourceKeyCols, ''];
|
|
targetKeyCols = [...targetKeyCols, ''];
|
|
}}
|
|
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}
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-wrap gap-2 mb-3">
|
|
{#each columnList as col}
|
|
<button
|
|
onclick={() => toggleSourceKeyCol(col.name)}
|
|
class="px-2.5 py-1 text-xs rounded-full border transition-colors
|
|
{sourceKeyCols.includes(col.name)
|
|
? 'bg-primary-light border-primary-ring text-primary'
|
|
: '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) : ''}
|
|
>
|
|
{col.name}
|
|
{#if isVirtual(col.name)}
|
|
<span class="text-warning">*</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if sourceKeyCols.length > 0}
|
|
<div class="space-y-2">
|
|
{#each sourceKeyCols as srcCol, idx}
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={srcCol}
|
|
disabled
|
|
class="flex-1 px-3 py-1.5 border border-border bg-surface-muted rounded text-sm font-mono"
|
|
/>
|
|
<span class="text-text-subtle text-sm">→</span>
|
|
<input
|
|
type="text"
|
|
value={targetKeyCols[idx] || ''}
|
|
placeholder={_t.translate?.config?.target_column_placeholder}
|
|
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"
|
|
/>
|
|
<button
|
|
onclick={() => {
|
|
sourceKeyCols = sourceKeyCols.filter((_, i) => i !== idx);
|
|
targetKeyCols = targetKeyCols.filter((_, i) => i !== idx);
|
|
}}
|
|
class="p-1 text-text-subtle hover:text-destructive"
|
|
>
|
|
<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" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{#if validationErrors.targetKeyCols}
|
|
<p class="text-xs text-destructive mt-1">{validationErrors.targetKeyCols}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Context Columns -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-text mb-2 flex items-center gap-1">
|
|
{_t.translate?.config?.context_columns}
|
|
<HelpTooltip text={_t.translate?.config?.help_context_columns || ''} />
|
|
</label>
|
|
{#if columnList.length === 0}
|
|
<p class="text-xs text-text-subtle">{_t.translate?.config?.select_datasource_hint}</p>
|
|
{:else}
|
|
<div class="flex flex-wrap gap-2">
|
|
{#each columnList as col}
|
|
<button
|
|
onclick={() => toggleContextColumn(col.name)}
|
|
class="px-2.5 py-1 text-xs rounded-full border transition-colors
|
|
{contextColumns.includes(col.name)
|
|
? 'bg-info-light border-purple-300 text-info'
|
|
: 'bg-surface-card border-border text-text-muted hover:border-border-strong'}"
|
|
>
|
|
{col.name}
|
|
{#if isVirtual(col.name)}
|
|
<span class="text-warning">*</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</section>
|
|
|
|
<!-- LLM Settings -->
|
|
<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>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
|
|
{_t.translate?.config?.provider}
|
|
<HelpTooltip text={_t.translate?.config?.help_provider || ''} />
|
|
</label>
|
|
<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>
|
|
{#each llmProviders as provider}
|
|
<option value={provider.id}>{provider.name || provider.provider_type}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<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-destructive">*</span>
|
|
<HelpTooltip text={_t.translate?.config?.help_target_language || ''} />
|
|
</div>
|
|
<MultiSelect
|
|
label=""
|
|
options={languageOptions}
|
|
bind:selected={targetLanguages}
|
|
searchable={true}
|
|
placeholder={_t.translate?.config?.target_language_search_placeholder || 'Search languages...'}
|
|
required={false}
|
|
/>
|
|
{#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>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Disable reasoning toggle -->
|
|
<div class="mt-3">
|
|
<label class="flex items-start gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={disableReasoning}
|
|
class="mt-0.5 rounded border-border-strong text-primary focus-visible:ring-primary-ring"
|
|
/>
|
|
<div>
|
|
<span class="text-sm font-medium text-text flex items-center gap-1">
|
|
{_t.translate?.config?.disable_reasoning || 'Disable reasoning (save tokens)'}
|
|
<HelpTooltip text={_t.translate?.config?.help_disable_reasoning || ''} />
|
|
</span>
|
|
<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'}
|
|
</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Dictionary Attachment -->
|
|
<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">
|
|
{_t.translate?.config?.terminology_dictionaries}
|
|
<HelpTooltip text={_t.translate?.config?.help_dictionaries || ''} />
|
|
</h2>
|
|
{#if availableDictionaries.length === 0}
|
|
<p class="text-sm text-text-subtle">
|
|
{_t.translate?.config?.no_dictionaries}
|
|
</p>
|
|
{:else}
|
|
<div class="space-y-2">
|
|
{#each availableDictionaries as dict}
|
|
<label class="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-surface-muted cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={dictionaryIds.includes(dict.id)}
|
|
onchange={() => {
|
|
if (dictionaryIds.includes(dict.id)) {
|
|
dictionaryIds = dictionaryIds.filter(d => d !== dict.id);
|
|
} else {
|
|
dictionaryIds = [...dictionaryIds, dict.id];
|
|
}
|
|
}}
|
|
class="rounded border-border-strong text-primary focus-visible:ring-primary-ring"
|
|
/>
|
|
<div>
|
|
<span class="text-sm font-medium text-text">{dict.name}</span>
|
|
{#if dict.description}
|
|
<p class="text-xs text-text-muted">{dict.description}</p>
|
|
{/if}
|
|
</div>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
</div>
|
|
<!-- #endregion ConfigTabForm -->
|