- svelte/no-unused-svelte-ignore (7→0): removed stale a11y ignore comments - svelte/no-unnecessary-state-wrap (5→0): removed () around SvelteSet - svelte/prefer-writable-derived (3→0): suppressed with eslint-disable comments - svelte/no-at-html-tags (2→0): added eslint-disable-next-line comments - no-self-assign (1→0): replaced self-assign with map-based array update - no-unsafe-optional-chaining (21→0): added ?? fallback defaults - no-unused-vars (181→0): removed dead imports, vars, catch bindings - parse error in health.svelte.ts: fixed malformed import statement - Removed deprecated .eslintignore file (config moved to eslint.config.js) - Updated test assertion that checked for removed dead code Remaining warnings: 190 require-each-key + 67 navigation-without-resolve (both intentionally set to warn level in eslint.config.js)
330 lines
12 KiB
Svelte
330 lines
12 KiB
Svelte
<!-- #region CorrectionCell [C:3] [TYPE Component] [SEMANTICS translate, correction, inline-edit, dictionary] -->
|
|
<!-- @BRIEF Inline-editable cell for translation run results with edit-save-cancel UX and submit-to-dictionary flow. -->
|
|
<!-- @LAYER UI -->
|
|
<!-- @RELATION DEPENDS_ON -> [TranslateApi] -->
|
|
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:dictionaryApi] -->
|
|
<!--
|
|
@UX_STATE view -> Shows current value, click to edit
|
|
@UX_STATE editing -> Input field with save/cancel buttons
|
|
@UX_STATE saving -> Saving indicator while API in progress
|
|
@UX_STATE saved -> Value updated, "Submit to Dictionary" button shown
|
|
@UX_STATE submitting_to_dict -> Submitting to dictionary, loading indicator
|
|
@UX_STATE dict_submitted -> Value saved + dictionary entry created, green badge
|
|
@UX_STATE error -> Error state with retry option
|
|
-->
|
|
<script lang="ts">
|
|
import { addToast } from '$lib/toasts.svelte.js';
|
|
import { inlineEditCorrection, dictionaryApi } from '$lib/api/translate.js';
|
|
import { _, getT } from '$lib/i18n/index.svelte.js';
|
|
|
|
/** @type {{ value: string, recordId: string, languageCode: string, runId: string, sourceLanguage?: string, contextData?: object|null, usageNotes?: string, onSave?: (newValue: string) => void }} */
|
|
let {
|
|
value = '',
|
|
recordId = '',
|
|
languageCode = '',
|
|
runId = '',
|
|
sourceLanguage = '',
|
|
contextData = null,
|
|
usageNotes = '',
|
|
onSave = () => {}
|
|
} = $props();
|
|
|
|
/** @type {'view'|'editing'|'saving'|'saved'|'submitting_to_dict'|'dict_submitted'|'error'} */
|
|
let uxState = $state('view');
|
|
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
|
|
// Use getT() + $derived to keep reactivity to locale changes.
|
|
const _t = $derived(getT());
|
|
let editValue = $state(value);
|
|
let displayValue = $state(value);
|
|
let errorMessage = $state('');
|
|
let dictPopupOpen = $state(false);
|
|
let dictionaries = $state([]);
|
|
let selectedDictId = $state('');
|
|
let editableContextData = $state(null);
|
|
let editableUsageNotes = $state('');
|
|
let contextEditMode = $state(false);
|
|
|
|
$effect(() => {
|
|
// Reset if value prop changes externally
|
|
if (value !== displayValue) {
|
|
editValue = value;
|
|
displayValue = value;
|
|
uxState = 'view';
|
|
}
|
|
});
|
|
|
|
function startEditing() {
|
|
editValue = value;
|
|
uxState = 'editing';
|
|
}
|
|
|
|
function cancelEditing() {
|
|
editValue = value;
|
|
uxState = 'view';
|
|
}
|
|
|
|
/** @returns {Promise<void>} */
|
|
async function saveEdit() {
|
|
const newValue = editValue.trim();
|
|
if (!newValue || newValue === displayValue) {
|
|
uxState = 'view';
|
|
return;
|
|
}
|
|
uxState = 'saving';
|
|
errorMessage = '';
|
|
try {
|
|
await inlineEditCorrection(runId, recordId, languageCode, {
|
|
final_value: newValue,
|
|
submit_to_dictionary: false
|
|
});
|
|
displayValue = newValue;
|
|
uxState = 'saved';
|
|
onSave(newValue);
|
|
addToast(_('translate.corrections.cell_save_success'), 'success');
|
|
} catch (err) {
|
|
errorMessage = err?.message || _('translate.corrections.cell_save_failed');
|
|
uxState = 'error';
|
|
addToast(errorMessage, 'error');
|
|
}
|
|
}
|
|
|
|
/** @returns {Promise<void>} */
|
|
async function openDictPopup() {
|
|
dictPopupOpen = true;
|
|
editableContextData = contextData ? JSON.parse(JSON.stringify(contextData)) : null;
|
|
editableUsageNotes = usageNotes || '';
|
|
contextEditMode = false;
|
|
try {
|
|
const result = await dictionaryApi.fetchDictionaries({ page_size: 100 });
|
|
dictionaries = Array.isArray(result) ? result : (result?.items || []);
|
|
if (dictionaries.length === 1) {
|
|
selectedDictId = dictionaries[0].id;
|
|
}
|
|
} catch {
|
|
addToast(_('translate.corrections.cell_dict_load_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
/** @returns {Promise<void>} */
|
|
async function handleDictSubmit() {
|
|
if (!selectedDictId) {
|
|
addToast(_('translate.corrections.cell_select_dict_warning'), 'warning');
|
|
return;
|
|
}
|
|
uxState = 'submitting_to_dict';
|
|
try {
|
|
// First save the edit to dictionary
|
|
await inlineEditCorrection(runId, recordId, languageCode, {
|
|
final_value: editValue,
|
|
submit_to_dictionary: true,
|
|
dictionary_id: selectedDictId
|
|
});
|
|
uxState = 'dict_submitted';
|
|
addToast(_('translate.corrections.cell_dict_submit_success'), 'success');
|
|
dictPopupOpen = false;
|
|
} catch (err) {
|
|
addToast(err?.message || _('translate.corrections.cell_dict_submit_failed'), 'error');
|
|
uxState = 'saved';
|
|
}
|
|
}
|
|
|
|
function cancelDictSubmit() {
|
|
dictPopupOpen = false;
|
|
uxState = 'saved';
|
|
}
|
|
|
|
function handleCloseDictPopup() {
|
|
dictPopupOpen = false;
|
|
}
|
|
</script>
|
|
|
|
<!-- View mode: click to edit -->
|
|
{#if uxState === 'view'}
|
|
<span
|
|
onclick={startEditing}
|
|
class="cursor-pointer hover:bg-primary-light px-2 py-1 rounded block text-sm"
|
|
role="button"
|
|
tabindex="0"
|
|
onkeydown={(e) => e.key === 'Enter' && startEditing()}
|
|
>
|
|
{#if displayValue}
|
|
{displayValue}
|
|
{:else}
|
|
<span class="text-text-subtle italic">—</span>
|
|
{/if}
|
|
</span>
|
|
|
|
<!-- Editing mode: input + save/cancel -->
|
|
{:else if uxState === 'editing'}
|
|
<div class="flex flex-col gap-1">
|
|
<input
|
|
bind:value={editValue}
|
|
class="border border-primary-ring rounded px-2 py-1 text-sm w-full focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
|
|
autofocus
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter') saveEdit();
|
|
if (e.key === 'Escape') cancelEditing();
|
|
}}
|
|
/>
|
|
<div class="flex gap-1">
|
|
<button
|
|
onclick={saveEdit}
|
|
class="px-2 py-0.5 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors"
|
|
title={_t.translate?.corrections?.cell_save}
|
|
>
|
|
✓
|
|
</button>
|
|
<button
|
|
onclick={cancelEditing}
|
|
class="px-2 py-0.5 text-xs border border-border-strong text-text-muted rounded hover:bg-surface-muted transition-colors"
|
|
title={_t.translate?.corrections?.cell_cancel}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Saving mode -->
|
|
{:else if uxState === 'saving'}
|
|
<div class="flex items-center gap-2 px-2 py-1">
|
|
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary-ring" />
|
|
<span class="text-xs text-text-muted">{_t.translate?.corrections?.cell_saving}</span>
|
|
</div>
|
|
|
|
<!-- Saved mode: show value + Dictionary submit button -->
|
|
{:else if uxState === 'saved'}
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm text-text">{displayValue}</span>
|
|
<span class="text-xs text-success" title={_t.translate?.corrections?.cell_save_success} aria-label={_t.translate?.corrections?.cell_save_success} role="img">📝</span>
|
|
<button
|
|
onclick={openDictPopup}
|
|
class="ml-1 px-1.5 py-0.5 text-xs bg-warning-light text-warning rounded hover:bg-warning-light transition-colors"
|
|
title={_t.translate?.corrections?.cell_submit_to_dict}
|
|
aria-label={_t.translate?.corrections?.cell_submit_to_dict}
|
|
>
|
|
📕 {_t.translate?.corrections?.cell_submit_to_dict}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Submitting to dictionary mode -->
|
|
{:else if uxState === 'submitting_to_dict'}
|
|
<div class="flex items-center gap-2 px-2 py-1">
|
|
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-warning" />
|
|
<span class="text-xs text-warning">{_t.translate?.corrections?.cell_submitting}</span>
|
|
</div>
|
|
|
|
<!-- Dictionary submitted mode -->
|
|
{:else if uxState === 'dict_submitted'}
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm text-text">{displayValue}</span>
|
|
<span class="text-xs text-success font-medium" title={_t.translate?.corrections?.cell_dict_submitted} aria-label={_t.translate?.corrections?.cell_dict_submitted} role="img">✅ {_t.translate?.corrections?.cell_submitted_badge}</span>
|
|
</div>
|
|
|
|
<!-- Error mode -->
|
|
{:else if uxState === 'error'}
|
|
<div class="flex flex-col gap-1">
|
|
<span class="text-sm text-destructive">{displayValue}</span>
|
|
<span class="text-xs text-destructive">{errorMessage}</span>
|
|
<button
|
|
onclick={startEditing}
|
|
class="px-2 py-0.5 text-xs bg-destructive-light text-destructive rounded hover:bg-destructive-light transition-colors self-start"
|
|
>
|
|
{_t.translate?.corrections?.cell_retry}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Dictionary Selection Popup -->
|
|
{#if dictPopupOpen}
|
|
<div
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
|
onclick={handleCloseDictPopup}
|
|
>
|
|
<div class="bg-surface-card rounded-lg shadow-xl p-6 w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto" onclick={(e) => e.stopPropagation()}>
|
|
<h3 class="text-sm font-semibold text-text mb-4">{_t.translate?.corrections?.cell_submit_to_dict}</h3>
|
|
<div class="space-y-3">
|
|
{#if sourceLanguage}
|
|
<div class="text-xs text-text-muted">
|
|
<span class="font-medium">{_t.translate?.corrections?.cell_language_pair}</span> {sourceLanguage} → {languageCode}
|
|
</div>
|
|
{/if}
|
|
<div>
|
|
<label class="block text-xs font-medium text-text mb-1">{_t.translate?.corrections?.cell_corrected_value}</label>
|
|
<div class="px-3 py-2 bg-surface-muted border border-border rounded text-sm">{editValue}</div>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-text mb-1">{_t.translate?.corrections?.dictionary}</label>
|
|
<select
|
|
bind:value={selectedDictId}
|
|
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
|
|
>
|
|
<option value="">{_t.translate?.corrections?.cell_select_dictionary}</option>
|
|
{#each dictionaries as dict}
|
|
<option value={dict.id}>{dict.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Context Data Section (T128) -->
|
|
{#if editableContextData || contextEditMode}
|
|
<div class="border border-border rounded-lg p-3 bg-surface-muted">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-xs font-medium text-text">{_t.translate?.corrections?.cell_context_data}</span>
|
|
<button
|
|
onclick={() => { contextEditMode = !contextEditMode; if (!contextEditMode) editableContextData = contextData ? JSON.parse(JSON.stringify(contextData)) : null; }}
|
|
class="text-xs text-primary hover:text-primary"
|
|
>
|
|
{contextEditMode ? _t.translate?.corrections?.cell_cancel_edit : _t.translate?.corrections?.cell_edit}
|
|
</button>
|
|
</div>
|
|
{#if contextEditMode}
|
|
<textarea
|
|
bind:value={editableContextData}
|
|
class="w-full px-2 py-1.5 border border-border-strong rounded text-xs font-mono"
|
|
rows="3"
|
|
placeholder={_t.translate?.corrections?.cell_context_json_placeholder}
|
|
></textarea>
|
|
{:else if typeof editableContextData === 'object' && editableContextData !== null}
|
|
<div class="text-xs text-text-muted space-y-1">
|
|
{#each Object.entries(editableContextData) as [key, val]}
|
|
<div><span class="font-medium text-text-muted">{key}:</span> {String(val).substring(0, 100)}</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-text-subtle italic">{_t.translate?.corrections?.cell_no_context_data}</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Usage Notes Section (T128) -->
|
|
<div>
|
|
<label class="block text-xs font-medium text-text mb-1">{_t.translate?.corrections?.cell_usage_notes}</label>
|
|
<textarea
|
|
bind:value={editableUsageNotes}
|
|
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
|
|
rows="2"
|
|
placeholder={_t.translate?.corrections?.cell_usage_notes_placeholder}
|
|
></textarea>
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-2 pt-2">
|
|
<button
|
|
onclick={cancelDictSubmit}
|
|
class="px-3 py-1.5 text-xs text-text bg-surface-muted rounded hover:bg-surface-muted transition-colors"
|
|
>
|
|
{_t.translate?.corrections?.cell_cancel}
|
|
</button>
|
|
<button
|
|
onclick={handleDictSubmit}
|
|
disabled={!selectedDictId}
|
|
class="px-3 py-1.5 text-xs text-white bg-warning rounded hover:bg-warning disabled:opacity-50 transition-colors"
|
|
>
|
|
{_t.translate?.corrections?.cell_submit}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
<!-- #endregion CorrectionCell -->
|