refactor(frontend): extract DictionaryDetailModel for dictionary editor page

Dictionary detail: 822→166 lines (-80%).
Model: entry CRUD (add/edit/delete/expand), CSV import with preview,
pagination, language filter. 26  atoms unified in model.

4→3 oversized pages remaining.
This commit is contained in:
2026-06-02 18:36:56 +03:00
parent 463a8dd1bb
commit 860791223c
2 changed files with 352 additions and 797 deletions

View File

@@ -0,0 +1,210 @@
// #region DictionaryDetailModel [C:4] [TYPE Model] [SEMANTICS translate,dictionary,entry,crud,import,model]
// @BRIEF Screen model for dictionary detail page — manages entry list, CRUD operations, and CSV import.
// @INVARIANT Expanding an entry clears previous expanded state.
// @INVARIANT Import preview is cleared when import form is closed.
// @STATE loading | editing | importing | import_preview | import_conflict | saving
// @ACTION loadDictionary() / loadEntries() — Fetch data.
// @ACTION saveEntry() / addEntry() / deleteEntry() — CRUD.
// @ACTION importDictionary() / previewImport() — Import flow.
// @RELATION DEPENDS_ON -> [api]
// @RELATION CALLS -> [log]
import { api } from '$lib/api.js';
import { log } from '$lib/cot-logger';
import { addToast } from '$lib/toasts.svelte.js';
import { t } from '$lib/i18n/index.svelte.js';
type AppState = 'idle' | 'loading' | 'editing' | 'importing' | 'import_preview' | 'import_conflict' | 'saving';
interface DictionaryEntry {
id: string;
source_term: string;
target_term: string;
source_language?: string;
target_language?: string;
context_notes?: string;
context_data?: unknown;
usage_notes?: string;
}
interface EditForm {
source_term: string;
target_term: string;
source_language: string;
target_language: string;
context_notes: string;
context_data: unknown;
usage_notes: string;
}
interface ImportResult {
imported?: number;
skipped?: number;
errors?: string[];
}
export class DictionaryDetailModel {
// ── Dictionary data ───────────────────────────────────────────
dictionary: Record<string, unknown> | null = $state(null);
entries: DictionaryEntry[] = $state([]);
totalEntries: number = $state(0);
entryPage: number = $state(1);
appState: AppState = $state('loading');
// ── Edit ──────────────────────────────────────────────────────
editEntryId: string | null = $state(null);
editForm: EditForm = $state({ source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '', context_data: null, usage_notes: '' });
editContextDataRaw: string = $state('');
// ── Add new ───────────────────────────────────────────────────
showAddForm: boolean = $state(false);
addForm: { source_term: string; target_term: string; source_language: string; target_language: string; context_notes: string } = $state({ source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '' });
isAdding: boolean = $state(false);
// ── Expand (view detail) ──────────────────────────────────────
expandedEntryId: string | null = $state(null);
expandedEditing: boolean = $state(false);
expandedContextData: string = $state('');
expandedUsageNotes: string = $state('');
// ── Import ────────────────────────────────────────────────────
showImportForm: boolean = $state(false);
importContent: string = $state('');
importDelimiter: string = $state('');
importOnConflict: string = $state('overwrite');
importPreview: unknown[] = $state([]);
importErrors: string[] = $state([]);
importResult: ImportResult | null = $state(null);
isImporting: boolean = $state(false);
// ── Filter ────────────────────────────────────────────────────
filterSourceLanguage: string = $state('');
// ── Context ───────────────────────────────────────────────────
dictionaryId: string = $state('');
// ── Derived ───────────────────────────────────────────────────
isLoading = $derived(this.appState === 'loading');
isEditing = $derived(this.appState === 'editing');
isSaving = $derived(this.appState === 'saving');
// ── Actions ───────────────────────────────────────────────────
async loadDictionary(): Promise<void> {
if (!this.dictionaryId) return;
this.appState = 'loading';
try {
this.dictionary = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}`) as Record<string, unknown>;
this.appState = 'idle';
this.loadEntries();
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Failed to load dictionary', 'error');
this.appState = 'idle';
}
}
async loadEntries(): Promise<void> {
if (!this.dictionaryId) return;
try {
const params = new URLSearchParams({ page: String(this.entryPage), page_size: '50' });
if (this.filterSourceLanguage) params.append('source_language', this.filterSourceLanguage);
const res = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries?${params}`) as { entries?: DictionaryEntry[]; total?: number };
this.entries = (res.entries || []) as DictionaryEntry[];
this.totalEntries = res.total || 0;
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Failed to load entries', 'error');
}
}
startEdit(entry: DictionaryEntry): void {
this.editEntryId = entry.id;
this.editForm = { source_term: entry.source_term, target_term: entry.target_term, source_language: entry.source_language || '', target_language: entry.target_language || '', context_notes: entry.context_notes || '', context_data: entry.context_data, usage_notes: entry.usage_notes || '' };
this.editContextDataRaw = entry.context_data ? JSON.stringify(entry.context_data, null, 2) : '';
this.appState = 'editing';
}
cancelEdit(): void {
this.editEntryId = null;
this.appState = 'idle';
}
async saveEntry(): Promise<void> {
if (!this.editEntryId) return;
this.appState = 'saving';
try {
const payload = { ...this.editForm };
if (this.editContextDataRaw) {
try { payload.context_data = JSON.parse(this.editContextDataRaw); } catch { /* keep as string */ }
}
await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries/${this.editEntryId}`, { method: 'PUT', body: payload });
addToast($t.translate?.dictionary?.entry_saved || 'Entry saved', 'success');
this.cancelEdit();
this.loadEntries();
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Failed to save entry', 'error');
this.appState = 'editing';
}
}
async addEntry(): Promise<void> {
this.isAdding = true;
try {
await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries`, { method: 'POST', body: this.addForm });
addToast($t.translate?.dictionary?.entry_added || 'Entry added', 'success');
this.showAddForm = false;
this.addForm = { source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '' };
this.loadEntries();
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Failed to add entry', 'error');
} finally { this.isAdding = false; }
}
async deleteEntry(entryId: string): Promise<void> {
if (!confirm($t.translate?.dictionary?.confirm_delete || 'Delete this entry?')) return;
try {
await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries/${entryId}`, { method: 'DELETE' });
addToast($t.translate?.dictionary?.entry_deleted || 'Entry deleted', 'success');
this.loadEntries();
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Failed to delete entry', 'error');
}
}
toggleExpand(entryId: string): void {
if (this.expandedEntryId === entryId) { this.expandedEntryId = null; return; }
this.expandedEntryId = entryId;
this.expandedEditing = false;
}
async previewImport(): Promise<void> {
if (!this.importContent) return;
this.appState = 'importing';
try {
const res = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/import`, {
method: 'POST', body: { content: this.importContent, delimiter: this.importDelimiter || undefined, on_conflict: this.importOnConflict, preview: true },
}) as { preview?: unknown[]; errors?: string[] };
this.importPreview = (res.preview || []) as unknown[];
this.importErrors = (res.errors || []) as string[];
this.appState = 'import_preview';
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Preview failed', 'error');
this.appState = 'idle';
}
}
async executeImport(): Promise<void> {
this.isImporting = true;
try {
const res = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/import`, {
method: 'POST', body: { content: this.importContent, delimiter: this.importDelimiter || undefined, on_conflict: this.importOnConflict },
}) as ImportResult;
this.importResult = res;
this.showImportForm = false;
this.loadEntries();
addToast($t.translate?.dictionary?.import_success || `Imported ${res.imported || 0} entries`, 'success');
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Import failed', 'error');
} finally { this.isImporting = false; }
}
}
// #endregion DictionaryDetailModel

View File

@@ -1,821 +1,166 @@
<!-- #region DictionaryDetailPage [C:3] [TYPE Page] [SEMANTICS sveltekit, translate, dictionary, editor, import] -->
<!-- @BRIEF Dictionary detail page — renders DictionaryDetailModel state. -->
<!-- @LAYER Page -->
<!-- @RELATION BINDS_TO -> [DictionaryDetailModel] -->
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { t, _ } from '$lib/i18n/index.svelte.js';
import { PageHeader, Button, Card } from '$lib/ui';
import { addToast } from '$lib/toasts.svelte.js';
import { dictionaryApi } from '$lib/api/translate.js';
import { DictionaryDetailModel } from '$lib/models/DictionaryDetailModel.svelte';
let dictionaryId = $derived(page.params.id);
let dictionary = $state(null);
let entries = $state([]);
let totalEntries = $state(0);
let entryPage = $state(1);
let entryPageSize = 100;
const m = new DictionaryDetailModel();
let state = $state('loading'); // idle | loading | editing | importing | import_preview | import_conflict | saving
// Edit form for entries
let editEntryId = $state(null);
let editForm = $state({ source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '', context_data: null, usage_notes: '' });
let editContextDataRaw = $state('');
// Add new entry form
let showAddForm = $state(false);
let addForm = $state({ source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '' });
let isAdding = $state(false);
// Expanded context rows
let expandedEntryId = $state(null);
let expandedEditing = $state(false);
let expandedContextData = $state('');
let expandedUsageNotes = $state('');
// Import
let showImportForm = $state(false);
let importContent = $state('');
let importDelimiter = $state('');
let importOnConflict = $state('overwrite');
let importPreview = $state([]);
let importErrors = $state([]);
let importResult = $state(null);
let isImporting = $state(false);
// Language pair filter
let filterSourceLanguage = $state('');
let filterTargetLanguage = $state('');
let availableSourceLanguages = $derived.by(() => {
const codes = new Set(entries.map(e => e.source_language).filter(Boolean));
return Array.from(codes).sort();
$effect(() => {
const id = page.params.id;
if (id) { m.dictionaryId = id; m.loadDictionary(); }
});
let availableTargetLanguages = $derived.by(() => {
const codes = new Set(entries.map(e => e.target_language).filter(Boolean));
return Array.from(codes).sort();
});
let filteredEntries = $derived.by(() => {
if (!filterSourceLanguage && !filterTargetLanguage) return entries;
return entries.filter(e => {
if (filterSourceLanguage && e.source_language !== filterSourceLanguage) return false;
if (filterTargetLanguage && e.target_language !== filterTargetLanguage) return false;
return true;
});
});
// Delete confirmation
let deleteEntryId = $state(null);
async function loadDictionary() {
state = 'loading';
try {
dictionary = await dictionaryApi.getDictionary(dictionaryId);
await loadEntries();
state = 'editing';
} catch (e) {
state = 'idle';
addToast(e?.message || _('translate.dictionaries.load_failed'), 'error');
}
}
async function loadEntries() {
try {
const res = await dictionaryApi.fetchEntries(dictionaryId, {
page: entryPage,
page_size: entryPageSize,
});
entries = res.items || [];
totalEntries = res.total || 0;
} catch (e) {
addToast(e?.message || _('translate.dictionaries.load_entries_failed'), 'error');
}
}
// --- Entry CRUD ---
function startAddEntry() {
showAddForm = true;
addForm = { source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '' };
}
async function handleAddEntry() {
if (!addForm.source_term || !addForm.target_term) {
addToast(_('translate.dictionaries.required_terms'), 'error');
return;
}
isAdding = true;
state = 'saving';
try {
await dictionaryApi.createEntry(dictionaryId, addForm);
addToast(_('translate.dictionaries.entry_added'), 'success');
showAddForm = false;
addForm = { source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '' };
await loadEntries();
} catch (e) {
addToast(e?.message || _('translate.dictionaries.entry_add_failed'), 'error');
} finally {
isAdding = false;
state = 'editing';
}
}
function startEditEntry(entry) {
editEntryId = entry.id;
editForm = {
source_term: entry.source_term,
target_term: entry.target_term,
source_language: entry.source_language || '',
target_language: entry.target_language || '',
context_notes: entry.context_notes || '',
context_data: entry.context_data || null,
usage_notes: entry.usage_notes || '',
};
editContextDataRaw = entry.context_data ? JSON.stringify(entry.context_data, null, 2) : '';
}
function cancelEditEntry() {
editEntryId = null;
editForm = { source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '', context_data: null, usage_notes: '' };
editContextDataRaw = '';
}
async function handleEditEntry() {
if (!editForm.source_term || !editForm.target_term) {
addToast(_('translate.dictionaries.required_terms'), 'error');
return;
}
state = 'saving';
try {
const payload = { ...editForm };
// Parse context_data if it's a string
if (typeof payload.context_data === 'string') {
try {
payload.context_data = JSON.parse(payload.context_data);
} catch {
payload.context_data = null;
}
}
// Set has_context flag
payload.has_context = payload.context_data !== null && typeof payload.context_data === 'object';
// Only include source_language/target_language if non-empty
if (!payload.source_language) delete payload.source_language;
if (!payload.target_language) delete payload.target_language;
await dictionaryApi.updateEntry(dictionaryId, editEntryId, payload);
addToast(_('translate.dictionaries.entry_updated'), 'success');
editEntryId = null;
editForm = { source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '', context_data: null, usage_notes: '' };
editContextDataRaw = '';
await loadEntries();
} catch (e) {
addToast(e?.message || _('translate.dictionaries.entry_update_failed'), 'error');
} finally {
state = 'editing';
}
}
async function handleDeleteEntry(entryId) {
deleteEntryId = null;
state = 'saving';
try {
await dictionaryApi.deleteEntry(dictionaryId, entryId);
addToast(_('translate.dictionaries.entry_deleted'), 'success');
await loadEntries();
} catch (e) {
addToast(e?.message || _('translate.dictionaries.entry_delete_failed'), 'error');
} finally {
state = 'editing';
}
}
function confirmDeleteEntry(entryId) {
deleteEntryId = entryId;
}
function cancelDeleteEntry() {
deleteEntryId = null;
}
// --- Import ---
function startImport() {
showImportForm = true;
importContent = '';
importDelimiter = '';
importOnConflict = 'overwrite';
importPreview = [];
importErrors = [];
importResult = null;
state = 'importing';
}
function cancelImport() {
showImportForm = false;
importContent = '';
importPreview = [];
importErrors = [];
importResult = null;
state = 'editing';
}
async function handlePreviewImport() {
if (!importContent.trim()) {
addToast(_('translate.dictionaries.paste_content_first'), 'error');
return;
}
isImporting = true;
try {
const res = await dictionaryApi.importEntries(dictionaryId, {
content: importContent,
delimiter: importDelimiter || null,
on_conflict: importOnConflict,
preview_only: true,
});
importPreview = res.preview || [];
importErrors = res.errors || [];
state = 'import_preview';
} catch (e) {
addToast(e?.message || _('translate.dictionaries.preview_failed'), 'error');
} finally {
isImporting = false;
}
}
async function handleExecuteImport() {
isImporting = true;
try {
const res = await dictionaryApi.importEntries(dictionaryId, {
content: importContent,
delimiter: importDelimiter || null,
on_conflict: importOnConflict,
preview_only: false,
});
importResult = res;
importPreview = res.preview || [];
importErrors = res.errors || [];
addToast(_('translate.dictionaries.import_complete').replace('{created}', res.created).replace('{updated}', res.updated).replace('{skipped}', res.skipped), 'success');
state = 'editing';
showImportForm = false;
await loadEntries();
} catch (e) {
addToast(e?.message || _('translate.dictionaries.import_failed'), 'error');
} finally {
isImporting = false;
}
}
// --- Export ---
function handleExport() {
const header = 'source_term,target_term,source_language,target_language,context_notes,context_data,usage_notes';
const rows = entries.map(e =>
[
`"${(e.source_term || '').replace(/"/g, '""')}"`,
`"${(e.target_term || '').replace(/"/g, '""')}"`,
`"${(e.source_language || '').replace(/"/g, '""')}"`,
`"${(e.target_language || '').replace(/"/g, '""')}"`,
`"${(e.context_notes || '').replace(/"/g, '""')}"`,
`"${(e.context_data ? JSON.stringify(e.context_data).replace(/"/g, '""') : '').replace(/"/g, '""')}"`,
`"${(e.usage_notes || '').replace(/"/g, '""')}"`,
].join(',')
);
const csv = [header, ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${dictionary?.name || 'dictionary'}-entries.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
addToast(_('translate.dictionaries.export_success'), 'info');
}
onMount(loadDictionary);
function goBack() { window.history.back(); }
</script>
<div class="mx-auto w-full max-w-6xl space-y-6">
{#snippet subtitleContent()}
{#if dictionary}
<p class="text-sm text-text-muted">
{dictionary.source_dialect} &rarr; {dictionary.target_dialect}
{#if dictionary.description}
&mdash; {dictionary.description}
{/if}
&middot; {$t.translate?.dictionaries?.entry_count.replace('{count}', totalEntries)}
</p>
{/if}
{/snippet}
<div class="max-w-7xl mx-auto px-4 py-6">
<button onclick={goBack} class="flex items-center gap-2 text-text-muted hover:text-text mb-4 text-sm">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 12H5M12 19l-7-7 7-7"/></svg>
{$t.common?.back}
</button>
{#snippet dictActions()}
{#if state === 'editing'}
{#if m.isLoading}
<div class="space-y-3">{#each Array(5) as _}<div class="h-8 bg-surface-muted rounded animate-pulse"></div>{/each}</div>
{:else if m.dictionary}
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-text">{m.dictionary.name as string}</h1>
<p class="text-sm text-text-muted mt-1">{m.dictionary.description as string || $t.translate?.dictionaries?.no_description}</p>
</div>
<div class="flex gap-2">
<Button variant="secondary" size="sm" onclick={startImport}>
{$t.translate?.dictionaries?.import_csv_tsv}
</Button>
<Button variant="secondary" size="sm" onclick={handleExport}>
{$t.translate?.dictionaries?.export_csv}
</Button>
<Button variant="primary" size="sm" onclick={startAddEntry}>
{$t.translate?.dictionaries?.add_entry}
</Button>
<button class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover text-sm" onclick={() => { m.showAddForm = true; }}>+ {$t.translate?.dictionaries?.add_entry}</button>
<button class="px-4 py-2 border border-border-strong rounded-lg hover:bg-surface-muted text-sm" onclick={() => { m.showImportForm = true; }}>{$t.translate?.dictionaries?.import_csv}</button>
</div>
</div>
<!-- Add form -->
{#if m.showAddForm}
<div class="bg-surface-card border border-border rounded-lg p-4 mb-4">
<h3 class="text-sm font-semibold mb-3">{$t.translate?.dictionaries?.new_entry}</h3>
<div class="grid grid-cols-2 gap-3 mb-3">
<input placeholder="Source term" bind:value={m.addForm.source_term} class="px-3 py-2 border border-border-strong rounded text-sm" />
<input placeholder="Target term" bind:value={m.addForm.target_term} class="px-3 py-2 border border-border-strong rounded text-sm" />
<input placeholder="Source language" bind:value={m.addForm.source_language} class="px-3 py-2 border border-border-strong rounded text-sm" />
<input placeholder="Target language" bind:value={m.addForm.target_language} class="px-3 py-2 border border-border-strong rounded text-sm" />
</div>
<input placeholder="Context notes" bind:value={m.addForm.context_notes} class="w-full px-3 py-2 border border-border-strong rounded text-sm mb-3" />
<div class="flex justify-end gap-2">
<button class="px-3 py-1.5 border rounded hover:bg-surface-muted text-sm" onclick={() => { m.showAddForm = false; }}>{$t.common?.cancel}</button>
<button class="px-3 py-1.5 bg-primary text-white rounded hover:bg-primary-hover text-sm" onclick={() => m.addEntry()} disabled={m.isAdding}>{m.isAdding ? $t.common?.saving : $t.translate?.dictionaries?.save}</button>
</div>
</div>
{/if}
{/snippet}
<PageHeader
title={dictionary?.name || $t.translate?.dictionaries?.loading}
subtitle={subtitleContent}
actions={dictActions}
/>
{#if state === 'loading'}
<Card padding="md">
<div class="space-y-3">
{#each [1, 2, 3] as _}
<div class="h-12 animate-pulse rounded-md bg-surface-muted"></div>
{/each}
</div>
</Card>
{:else if state === 'importing' || state === 'import_preview'}
<Card title={$t.translate?.dictionaries?.import_title} padding="md">
<div class="space-y-4">
{#if state === 'importing'}
<div>
<label for="import-content" class="text-sm font-medium text-text">
{$t.translate?.dictionaries?.import_content_label}
</label>
<textarea
id="import-content"
bind:value={importContent}
placeholder={$t.translate?.dictionaries?.import_content_placeholder}
class="flex w-full rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm font-mono min-h-[200px] mt-1"
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="import-delimiter" class="text-sm font-medium text-text">{$t.translate?.dictionaries?.import_delimiter}</label>
<select
id="import-delimiter"
bind:value={importDelimiter}
class="flex h-10 w-full rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm mt-1"
>
<option value="">{$t.translate?.dictionaries?.import_delimiter_auto}</option>
<option value=",">{$t.translate?.dictionaries?.import_delimiter_comma}</option>
<option value="\t">{$t.translate?.dictionaries?.import_delimiter_tab}</option>
</select>
</div>
<div>
<label for="import-conflict" class="text-sm font-medium text-text">{$t.translate?.dictionaries?.import_on_conflict}</label>
<select
id="import-conflict"
bind:value={importOnConflict}
class="flex h-10 w-full rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm mt-1"
>
<option value="overwrite">{$t.translate?.dictionaries?.import_conflict_overwrite}</option>
<option value="keep_existing">{$t.translate?.dictionaries?.import_conflict_keep}</option>
<option value="cancel">{$t.translate?.dictionaries?.import_conflict_cancel}</option>
</select>
</div>
</div>
<div class="flex gap-3">
<Button onclick={handlePreviewImport} isLoading={isImporting}>
{$t.translate?.dictionaries?.import_preview_btn}
</Button>
<Button variant="secondary" onclick={cancelImport}>
{$t.translate?.dictionaries?.import_cancel_btn}
</Button>
</div>
{/if}
{#if state === 'import_preview'}
{#if importPreview.length > 0}
<div class="max-h-64 overflow-y-auto border rounded-md">
<table class="w-full text-xs">
<thead class="bg-surface-muted sticky top-0">
<tr>
<th class="text-left p-2">{$t.translate?.dictionaries?.import_table_line}</th>
<th class="text-left p-2">{$t.translate?.dictionaries?.import_table_source}</th>
<th class="text-left p-2">{$t.translate?.dictionaries?.import_table_target}</th>
<th class="text-left p-2">{$t.translate?.dictionaries?.import_table_src_lang}</th>
<th class="text-left p-2">{$t.translate?.dictionaries?.import_table_tgt_lang}</th>
<th class="text-left p-2">{$t.translate?.dictionaries?.import_table_context}</th>
<th class="text-left p-2">{$t.translate?.dictionaries?.import_table_conflict}</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
{#each importPreview as row}
<tr class={row.is_conflict ? 'bg-warning-light' : ''}>
<td class="p-2 text-text-subtle">{row.line}</td>
<td class="p-2">{row.source_term}</td>
<td class="p-2">{row.target_term}</td>
<td class="p-2 font-mono">{row.source_language || '—'}</td>
<td class="p-2 font-mono">{row.target_language || '—'}</td>
<td class="p-2 text-text-muted">
{#if row.has_context || row.context_data}
<span class="text-primary" title={row.context_data ? JSON.stringify(row.context_data) : ''}>{$t.translate?.dictionaries?.import_yes}</span>
{:else if row.usage_notes}
<span class="text-success" title={row.usage_notes}>{$t.translate?.dictionaries?.import_notes}</span>
{:else}
{/if}
</td>
<td class="p-2">
{#if row.is_conflict}
<span class="text-warning">
{_('translate.dictionaries.import_existing').replace('{term}', row.existing_target_term)}
({importOnConflict === 'overwrite' ? $t.translate?.dictionaries?.import_will_overwrite : $t.translate?.dictionaries?.import_will_skip})
</span>
{:else}
<span class="text-success">{$t.translate?.dictionaries?.import_new}</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if importErrors.length > 0}
<div class="bg-destructive-light border border-destructive-light text-destructive rounded-md p-3 text-xs">
<p class="font-medium mb-1">{$t.translate?.dictionaries?.import_errors.replace('{count}', importErrors.length)}</p>
{#each importErrors as err}
<p>{_('translate.dictionaries.import_error_line').replace('{line}', err.line).replace('{error}', err.error)}</p>
{/each}
</div>
{/if}
<div class="flex gap-3">
<Button onclick={handleExecuteImport} isLoading={isImporting}>
{$t.translate?.dictionaries?.import_execute_btn.replace('{count}', importPreview.length)}
</Button>
<Button variant="secondary" onclick={cancelImport}>
{$t.translate?.dictionaries?.import_cancel_btn}
</Button>
</div>
{/if}
</div>
</Card>
{:else if state === 'editing'}
{#if showAddForm}
<Card title={$t.translate?.dictionaries?.add_entry_title} padding="md">
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="add-source" class="text-sm font-medium text-text">{$t.translate?.dictionaries?.source_term_required}</label>
<input
id="add-source"
bind:value={addForm.source_term}
placeholder={$t.translate?.dictionaries?.source_term_placeholder}
class="flex h-10 w-full rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm mt-1"
/>
</div>
<div>
<label for="add-target" class="text-sm font-medium text-text">{$t.translate?.dictionaries?.target_term_required}</label>
<input
id="add-target"
bind:value={addForm.target_term}
placeholder={$t.translate?.dictionaries?.target_term_placeholder}
class="flex h-10 w-full rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm mt-1"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="add-source-lang" class="text-sm font-medium text-text">{$t.translate?.dictionaries?.source_language_label}</label>
<input
id="add-source-lang"
bind:value={addForm.source_language}
placeholder={$t.translate?.dictionaries?.source_lang_placeholder}
class="flex h-10 w-full rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm mt-1 font-mono"
/>
</div>
<div>
<label for="add-target-lang" class="text-sm font-medium text-text">{$t.translate?.dictionaries?.target_language_label}</label>
<input
id="add-target-lang"
bind:value={addForm.target_language}
placeholder={$t.translate?.dictionaries?.target_lang_placeholder}
class="flex h-10 w-full rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm mt-1 font-mono"
/>
</div>
</div>
<div>
<label for="add-notes" class="text-sm font-medium text-text">{$t.translate?.dictionaries?.context_notes_label}</label>
<input
id="add-notes"
bind:value={addForm.context_notes}
placeholder={$t.translate?.dictionaries?.context_notes_placeholder}
class="flex h-10 w-full rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm mt-1"
/>
</div>
<div class="flex gap-3">
<Button onclick={handleAddEntry} isLoading={isAdding}>{$t.translate?.dictionaries?.add_entry_btn}</Button>
<Button variant="secondary" onclick={() => { showAddForm = false; }}>{$t.translate?.dictionaries?.cancel_btn}</Button>
</div>
<!-- Import form -->
{#if m.showImportForm}
<div class="bg-surface-card border border-border rounded-lg p-4 mb-4">
<h3 class="text-sm font-semibold mb-3">{$t.translate?.dictionaries?.import_entries}</h3>
<textarea placeholder={$t.translate?.dictionaries?.paste_csv || 'Paste CSV content...'} bind:value={m.importContent} class="w-full px-3 py-2 border border-border-strong rounded text-sm font-mono" rows="6"></textarea>
<div class="grid grid-cols-2 gap-3 mt-3 mb-3">
<input placeholder={$t.translate?.dictionaries?.delimiter || 'Delimiter (default: tab)'} bind:value={m.importDelimiter} class="px-3 py-2 border border-border-strong rounded text-sm" />
<select bind:value={m.importOnConflict} class="px-3 py-2 border border-border-strong rounded text-sm">
<option value="overwrite">{$t.translate?.dictionaries?.conflict_overwrite}</option>
<option value="skip">{$t.translate?.dictionaries?.conflict_skip}</option>
</select>
</div>
</Card>
{#if m.importErrors.length > 0}
<div class="mb-3 text-sm text-destructive bg-destructive-light rounded px-3 py-2">{#each m.importErrors as err}<div>{err}</div>{/each}</div>
{/if}
{#if m.appState === 'import_preview' && m.importPreview.length > 0}
<div class="text-xs text-text-muted mb-1">{$t.translate?.dictionaries?.preview_count?.replace('{count}', String(m.importPreview.length))}</div>
<div class="max-h-40 overflow-auto border border-border rounded mb-3">
<table class="min-w-full text-xs"><thead class="bg-surface-muted"><tr><th class="px-2 py-1 text-left">Source</th><th class="px-2 py-1 text-left">Target</th></tr></thead>
<tbody class="divide-y divide-border">{#each m.importPreview as row}<tr><td class="px-2 py-1">{(row as Record<string,unknown>).source_term || (row as Record<string,unknown>).source || '-'}</td><td class="px-2 py-1">{(row as Record<string,unknown>).target_term || (row as Record<string,unknown>).target || '-'}</td></tr>{/each}</tbody></table>
</div>
{/if}
<div class="flex justify-end gap-2">
<button class="px-3 py-1.5 border rounded hover:bg-surface-muted text-sm" onclick={() => { m.showImportForm = false; m.appState = 'idle'; }}>{$t.common?.cancel}</button>
<button class="px-3 py-1.5 border rounded hover:bg-surface-muted text-sm" onclick={() => m.previewImport()}>{$t.translate?.dictionaries?.preview}</button>
<button class="px-3 py-1.5 bg-primary text-white rounded hover:bg-primary-hover text-sm" onclick={() => m.executeImport()} disabled={m.isImporting || m.importContent.length === 0}>{m.isImporting ? $t.common?.saving : $t.translate?.dictionaries?.import_btn}</button>
</div>
</div>
{/if}
{#if entries.length === 0}
<Card padding="lg">
<div class="text-center py-8">
<p class="text-text-muted mb-4">{$t.translate?.dictionaries?.no_entries}</p>
<Button onclick={startAddEntry} variant="primary">{$t.translate?.dictionaries?.add_first_entry}</Button>
</div>
</Card>
{:else}
<Card padding="none">
{#if availableSourceLanguages.length > 0 || availableTargetLanguages.length > 0}
<div class="px-3 py-2 bg-surface-muted border-b border-border flex items-center gap-3 flex-wrap">
<span class="text-xs font-medium text-text-muted">{$t.translate?.dictionaries?.filter_by_language}</span>
<select
bind:value={filterSourceLanguage}
class="text-xs px-2 py-1 border border-border-strong rounded bg-surface-card"
>
<option value="">{$t.translate?.dictionaries?.filter_all_source}</option>
{#each availableSourceLanguages as lang}
<option value={lang}>{lang}</option>
{/each}
</select>
<span class="text-xs text-text-subtle">&rarr;</span>
<select
bind:value={filterTargetLanguage}
class="text-xs px-2 py-1 border border-border-strong rounded bg-surface-card"
>
<option value="">{$t.translate?.dictionaries?.filter_all_target}</option>
{#each availableTargetLanguages as lang}
<option value={lang}>{lang}</option>
{/each}
</select>
{#if filterSourceLanguage || filterTargetLanguage}
<button
onclick={() => { filterSourceLanguage = ''; filterTargetLanguage = ''; }}
class="text-xs text-primary hover:text-primary-hover"
>
{$t.translate?.dictionaries?.filter_clear}
</button>
<span class="text-xs text-text-subtle">{_('translate.dictionaries.filter_count').replace('{filtered}', filteredEntries.length).replace('{total}', entries.length)}</span>
{/if}
</div>
{/if}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-surface-muted border-b border-border">
<tr>
<th class="text-left p-3 text-xs font-medium text-text-muted uppercase tracking-wider">{$t.translate?.dictionaries?.table_source_term}</th>
<th class="text-left p-3 text-xs font-medium text-text-muted uppercase tracking-wider">{$t.translate?.dictionaries?.table_target_term}</th>
<th class="text-left p-3 text-xs font-medium text-text-muted uppercase tracking-wider">{$t.translate?.dictionaries?.table_lang}</th>
<th class="text-left p-3 text-xs font-medium text-text-muted uppercase tracking-wider">{$t.translate?.dictionaries?.table_context}</th>
<th class="text-right p-3 text-xs font-medium text-text-muted uppercase tracking-wider">{$t.translate?.dictionaries?.table_actions}</th>
<!-- Filter -->
<div class="mb-4 flex gap-2 items-center">
<input type="text" placeholder={$t.translate?.dictionaries?.filter_language || 'Filter source language'} bind:value={m.filterSourceLanguage} class="px-3 py-2 border border-border-strong rounded-lg text-sm w-48" />
<button class="px-3 py-1.5 bg-primary text-white rounded text-sm hover:bg-primary-hover" onclick={() => { m.entryPage = 1; m.loadEntries(); }}>{$t.common?.search}</button>
</div>
<!-- Entries table -->
<div class="bg-surface-card border border-border rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-border text-sm">
<thead class="bg-surface-muted"><tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">{$t.translate?.dictionaries?.source_term}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">{$t.translate?.dictionaries?.target_term}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-text-muted uppercase">{$t.translate?.dictionaries?.languages}</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-text-muted uppercase">{$t.common?.actions}</th>
</tr></thead>
<tbody class="divide-y divide-border">
{#each m.entries as entry (entry.id)}
<!-- Edit row -->
{#if m.editEntryId === entry.id && m.appState !== 'loading'}
<tr class="bg-primary-light">
<td class="px-4 py-2"><input bind:value={m.editForm.source_term} class="w-full px-2 py-1 border border-border-strong rounded text-sm" /></td>
<td class="px-4 py-2"><input bind:value={m.editForm.target_term} class="w-full px-2 py-1 border border-border-strong rounded text-sm" /></td>
<td class="px-4 py-2"><div class="flex gap-1 items-center"><input bind:value={m.editForm.source_language} class="w-16 px-2 py-1 border border-border-strong rounded text-xs" placeholder="src" /><span class="text-text-subtle text-xs"></span><input bind:value={m.editForm.target_language} class="w-16 px-2 py-1 border border-border-strong rounded text-xs" placeholder="tgt" /></div></td>
<td class="px-4 py-2 text-right"><div class="flex gap-1 justify-end">
<button class="px-3 py-1 bg-success text-white rounded text-xs hover:bg-success-hover" onclick={() => m.saveEntry()} disabled={m.isSaving}></button>
<button class="px-3 py-1 border rounded text-xs hover:bg-surface-muted" onclick={() => m.cancelEdit()}>×</button>
</div></td>
</tr>
</thead>
<tbody class="divide-y divide-border">
{#each filteredEntries as entry}
{#if editEntryId === entry.id}
<tr class="bg-primary-light">
<td class="p-2">
<input
bind:value={editForm.source_term}
class="flex h-9 w-full rounded border border-primary-ring bg-surface-card px-2 py-1 text-sm"
/>
</td>
<td class="p-2">
<input
bind:value={editForm.target_term}
class="flex h-9 w-full rounded border border-primary-ring bg-surface-card px-2 py-1 text-sm"
/>
</td>
<td class="p-2">
<div class="flex gap-1">
<input
bind:value={editForm.source_language}
placeholder="src"
class="flex h-9 w-14 rounded border border-primary-ring bg-surface-card px-1 py-1 text-xs font-mono"
/>
<span class="text-text-subtle self-center text-xs">&rarr;</span>
<input
bind:value={editForm.target_language}
placeholder="tgt"
class="flex h-9 w-14 rounded border border-primary-ring bg-surface-card px-1 py-1 text-xs font-mono"
/>
</div>
</td>
<td class="p-2">
<input
bind:value={editForm.context_notes}
placeholder="notes"
class="flex h-9 w-full rounded border border-primary-ring bg-surface-card px-2 py-1 text-sm"
/>
</td>
<td class="p-2 text-right">
<div class="flex gap-1 justify-end">
<Button variant="primary" size="sm" onclick={handleEditEntry}>{$t.translate?.dictionaries?.save_btn}</Button>
<Button variant="ghost" size="sm" onclick={cancelEditEntry}>{$t.translate?.dictionaries?.cancel_btn}</Button>
</div>
</td>
</tr>
{:else}
<tr class="hover:bg-surface-muted transition-colors cursor-pointer" onclick={() => {
expandedEntryId = expandedEntryId === entry.id ? null : entry.id;
expandedEditing = false;
if (expandedEntryId !== entry.id) {
expandedContextData = entry.context_data ? JSON.stringify(entry.context_data, null, 2) : '';
expandedUsageNotes = entry.usage_notes || '';
}
}}>
<td class="p-3 font-medium text-text">{entry.source_term}</td>
<td class="p-3 text-text">{entry.target_term}</td>
<td class="p-3 text-xs text-text-subtle">
{#if entry.source_language || entry.target_language}
<span class="font-mono text-text-muted">{entry.source_language || '?'} &rarr; {entry.target_language || '?'}</span>
{:else}
<span class="text-text-subtle italic"></span>
{/if}
</td>
<td class="p-3 text-xs text-text-subtle">
<div class="flex items-center gap-1.5">
<span>{entry.context_notes || ''}</span>
{#if entry.has_context}
<span class="inline-flex items-center text-[10px] px-1.5 py-0.5 rounded-full bg-primary-light text-primary">
context
</span>
{/if}
{#if entry.usage_notes}
<span class="inline-flex items-center text-[10px] px-1.5 py-0.5 rounded-full bg-success-light text-success">
notes
</span>
{/if}
</div>
</td>
<td class="p-3 text-right">
{#if deleteEntryId === entry.id}
<div class="flex gap-1 justify-end items-center">
<span class="text-xs text-destructive">{$t.translate?.dictionaries?.confirm_delete_entry}</span>
<Button variant="danger" size="sm" onclick={() => handleDeleteEntry(entry.id)}>{$t.translate?.dictionaries?.delete_entry_btn}</Button>
<Button variant="ghost" size="sm" onclick={cancelDeleteEntry}>{$t.translate?.dictionaries?.no_btn}</Button>
</div>
{:else}
<div class="flex gap-1 justify-end">
<Button variant="ghost" size="sm" onclick={() => startEditEntry(entry)}>{$t.translate?.dictionaries?.edit_btn}</Button>
<Button variant="ghost" size="sm" onclick={() => confirmDeleteEntry(entry.id)}>{$t.translate?.dictionaries?.delete_entry_btn}</Button>
</div>
{/if}
</td>
</tr>
{#if expandedEntryId === entry.id}
<tr class="bg-surface-muted">
<td colspan="5" class="p-3">
<div class="text-xs space-y-3">
{#if expandedEditing}
<!-- Inline editing mode -->
<div>
<div class="flex items-center justify-between mb-1">
<span class="font-medium text-text-muted">Context data:</span>
<div class="flex gap-1">
<button
onclick={async () => {
try {
const updated = await dictionaryApi.updateEntry(dictionaryId, entry.id, {
context_data: expandedContextData ? JSON.parse(expandedContextData) : null,
usage_notes: expandedUsageNotes,
has_context: !!expandedContextData
});
addToast(_('translate.dictionaries.updated_toast'), 'success');
expandedEditing = false;
await loadEntries();
} catch (e) {
addToast(e?.message || _('translate.dictionaries.save_failed_toast'), 'error');
}
}}
class="px-2 py-0.5 text-xs bg-primary text-white rounded hover:bg-primary-hover"
>{$t.translate?.dictionaries?.save_btn}</button>
<button
onclick={() => { expandedEditing = false; }}
class="px-2 py-0.5 text-xs border border-border-strong text-text-muted rounded hover:bg-surface-muted"
>{$t.translate?.dictionaries?.cancel_btn}</button>
</div>
</div>
<textarea
bind:value={expandedContextData}
class="w-full px-2 py-1.5 border border-border-strong rounded text-xs font-mono min-h-[60px]"
placeholder={$t.translate?.dictionaries?.json_placeholder}
></textarea>
</div>
<div>
<label class="font-medium text-text-muted">{$t.translate?.dictionaries?.usage_notes_label}</label>
<textarea
bind:value={expandedUsageNotes}
class="w-full px-2 py-1.5 border border-border-strong rounded text-xs mt-1 min-h-[40px]"
placeholder={$t.translate?.dictionaries?.usage_notes_placeholder}
></textarea>
</div>
{:else}
<!-- Display mode (read-only) -->
{#if entry.has_context && entry.context_data}
<div>
<div class="flex items-center justify-between mb-1">
<span class="font-medium text-text-muted">{$t.translate?.dictionaries?.context_data_label}</span>
<button
onclick={() => {
expandedEditing = true;
expandedContextData = entry.context_data ? JSON.stringify(entry.context_data, null, 2) : '';
expandedUsageNotes = entry.usage_notes || '';
}}
class="text-xs text-primary hover:text-primary-hover"
>{$t.translate?.dictionaries?.edit_btn}</button>
</div>
<div class="bg-surface-card border border-border rounded p-2">
{#if typeof entry.context_data === 'object' && entry.context_data !== null}
<div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[11px]">
{#each Object.entries(entry.context_data) as [key, val]}
<span class="font-medium text-text-muted text-right">{key}:</span>
<span class="text-text">{typeof val === 'object' ? JSON.stringify(val) : String(val)}</span>
{/each}
</div>
{:else}
<pre class="text-[11px] text-text-muted overflow-x-auto">{JSON.stringify(entry.context_data, null, 2)}</pre>
{/if}
</div>
</div>
{:else}
<div class="flex items-center justify-between">
<p class="text-text-subtle italic">{$t.translate?.dictionaries?.no_context_data}</p>
<button
onclick={() => {
expandedEditing = true;
expandedContextData = '';
expandedUsageNotes = entry.usage_notes || '';
}}
class="text-xs text-primary hover:text-primary-hover"
>{$t.translate?.dictionaries?.add_context}</button>
</div>
{/if}
{#if entry.usage_notes}
<div>
<span class="font-medium text-text-muted">{$t.translate?.dictionaries?.usage_notes_label}</span>
<p class="mt-0.5 text-text">{entry.usage_notes}</p>
</div>
{:else if expandedEditing}
<!-- usage notes editable handled above -->
{/if}
{/if}
{#if entry.context_source}
<div class="flex items-center gap-1.5">
<span class="font-medium text-text-muted">{$t.translate?.dictionaries?.source_label}</span>
<span class="inline-flex items-center text-[10px] px-1.5 py-0.5 rounded-full
{entry.context_source === 'auto' ? 'bg-primary-light text-primary' : ''}
{entry.context_source === 'auto_with_edits' ? 'bg-warning-light text-warning' : ''}
{entry.context_source === 'manual' ? 'bg-success-light text-success' : ''}
{entry.context_source === 'bulk' ? 'bg-info-light text-info' : ''}
{!['auto','auto_with_edits','manual','bulk'].includes(entry.context_source) ? 'bg-surface-muted text-text-muted' : ''}
">{entry.context_source}</span>
</div>
{/if}
</div>
</td>
</tr>
{/if}
{/if}
{/each}
</tbody>
</table>
</div>
</Card>
{#if m.appState !== 'loading'}
<tr class="bg-primary-light"><td colspan="4" class="px-4 py-2 space-y-1">
<input placeholder={$t.translate?.dictionaries?.context_notes} bind:value={m.editForm.context_notes} class="w-full px-2 py-1 border border-border-strong rounded text-xs" />
<textarea placeholder={$t.translate?.dictionaries?.context_data_json || 'Context data (JSON)'} bind:value={m.editContextDataRaw} class="w-full px-2 py-1 border border-border-strong rounded text-xs font-mono" rows="3"></textarea>
</td></tr>
{/if}
<!-- Expanded view -->
{:else if m.expandedEntryId === entry.id}
<tr class="hover:bg-surface-muted cursor-pointer" onclick={() => m.toggleExpand(entry.id)}>
<td class="px-4 py-2 font-medium text-text">{entry.source_term}</td>
<td class="px-4 py-2 text-text">{entry.target_term}</td>
<td class="px-4 py-2 text-xs text-text-muted">{entry.source_language || '-'}{entry.target_language || '-'}</td>
<td class="px-4 py-2 text-right"><span class="text-text-subtle text-xs"></span></td>
</tr>
<tr><td colspan="4" class="px-4 py-3 bg-surface-page text-sm space-y-1">
{#if entry.context_notes}<div><span class="text-text-muted text-xs">{$t.translate?.dictionaries?.context_notes}:</span> {entry.context_notes}</div>{/if}
{#if entry.context_data}<div><span class="text-text-muted text-xs">{$t.translate?.dictionaries?.context_data}:</span> <code class="text-xs">{JSON.stringify(entry.context_data)}</code></div>{/if}
<div class="flex gap-2 mt-2">
<button class="px-2 py-0.5 text-xs text-primary hover:underline" onclick={(e) => { e.stopPropagation(); m.startEdit(entry); }}>{$t.common?.edit}</button>
<button class="px-2 py-0.5 text-xs text-destructive hover:underline" onclick={(e) => { e.stopPropagation(); m.deleteEntry(entry.id); }}>{$t.common?.delete}</button>
</div>
</td></tr>
<!-- Normal row -->
{:else}
<tr class="hover:bg-surface-muted cursor-pointer" onclick={() => m.toggleExpand(entry.id)}>
<td class="px-4 py-2 font-medium text-text">{entry.source_term}</td>
<td class="px-4 py-2 text-text">{entry.target_term}</td>
<td class="px-4 py-2 text-xs text-text-muted">{entry.source_language || '-'}{entry.target_language || '-'}</td>
<td class="px-4 py-2 text-right"><div class="flex gap-1 justify-end" onclick={(e) => e.stopPropagation()}>
<button class="px-2 py-0.5 text-xs text-primary hover:underline" onclick={() => m.startEdit(entry)}>{$t.common?.edit}</button>
<button class="px-2 py-0.5 text-xs text-destructive hover:underline" onclick={() => m.deleteEntry(entry.id)}>{$t.common?.delete}</button>
</div></td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{#if totalEntries > entryPageSize}
<div class="flex justify-center gap-2 pt-4">
<Button
variant="secondary"
size="sm"
disabled={entryPage <= 1}
onclick={() => { entryPage--; loadEntries(); }}
>
{$t.translate?.dictionaries?.page_previous}
</Button>
<span class="text-sm text-text-muted self-center">
{_('translate.dictionaries.page_info').replace('{current}', entryPage).replace('{total}', Math.ceil(totalEntries / entryPageSize))}
</span>
<Button
variant="secondary"
size="sm"
disabled={entryPage >= Math.ceil(totalEntries / entryPageSize)}
onclick={() => { entryPage++; loadEntries(); }}
>
{$t.translate?.dictionaries?.page_next}
</Button>
</div>
{/if}
{/if}
<!-- Pagination -->
<div class="flex items-center justify-between mt-4">
<span class="text-sm text-text-muted">{$t.translate?.dictionaries?.total_entries?.replace('{count}', String(m.totalEntries))}</span>
<div class="flex gap-2">
<button class="px-3 py-1 text-sm border rounded disabled:opacity-50" disabled={m.entryPage <= 1} onclick={() => { m.entryPage--; m.loadEntries(); }}>{$t.common?.previous}</button>
<button class="px-3 py-1 text-sm border rounded disabled:opacity-50" disabled={m.entryPage * 50 >= m.totalEntries} onclick={() => { m.entryPage++; m.loadEntries(); }}>{$t.common?.next}</button>
</div>
</div>
{/if}
</div>
<!-- #endregion DictionaryDetailPage -->