From 8da203875978d58c7868f25167ef52cf84c08936 Mon Sep 17 00:00:00 2001 From: nikazzio Date: Thu, 4 Jun 2026 23:15:51 +0200 Subject: [PATCH 1/3] feat: update window dimensions and minimum size for better usability feat: enhance loading screen with dynamic height and width variables feat: implement cost panel in PipelineSidebar with improved positioning logic feat: add MemoriesTab component to manage phrase memory entries feat: integrate phrase memory management functions in phraseMemoryService feat: add localization for memories management in English and Italian fix: update libraryStore to include memories tab in state management style: adjust CSS for consistent minimum height and width across components --- src-tauri/src/lib.rs | 3 + src-tauri/src/vector/embedding.rs | 157 +++++++++ src-tauri/tauri.conf.json | 8 +- src/App.tsx | 6 +- src/components/layout/PipelineSidebar.tsx | 220 ++++++++++--- src/components/layout/PipelineStrip.tsx | 18 +- src/components/library/LibraryPanel.tsx | 32 +- src/components/library/MemoriesTab.tsx | 307 ++++++++++++++++++ src/components/workspace/WorkspaceWizard.tsx | 2 +- src/i18n/en.json | 23 +- src/i18n/it.json | 23 +- src/index.css | 16 + .../__tests__/phraseMemoryService.test.ts | 71 ++++ src/services/phraseMemoryService.ts | 92 ++++++ src/stores/libraryStore.ts | 2 +- 15 files changed, 892 insertions(+), 88 deletions(-) create mode 100644 src/components/library/MemoriesTab.tsx diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1b56a77..8d053d3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -86,6 +86,9 @@ llm::pipeline::run_stage, vector::embedding::get_embeddings, vector::embedding::split_phrases_llm, vector::embedding::vec_upsert_source_phrase, + vector::embedding::vec_list_phrase_memory, + vector::embedding::vec_delete_phrase_memory, + vector::embedding::vec_update_phrase_memory, vector::embedding::vec_search_phrase_memory, vector::embedding::vec_save_locked_phrases, ]) diff --git a/src-tauri/src/vector/embedding.rs b/src-tauri/src/vector/embedding.rs index f9d4d2f..0fa10ef 100644 --- a/src-tauri/src/vector/embedding.rs +++ b/src-tauri/src/vector/embedding.rs @@ -29,6 +29,43 @@ fn floats_to_blob(v: &[f32]) -> Vec { v.iter().flat_map(|f| f.to_le_bytes()).collect() } +fn ensure_source_phrase_embeddings_schema(conn: &rusqlite::Connection) -> Result<(), EmbeddingError> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS source_phrase_embeddings ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + chunk_id TEXT, + source_phrase TEXT NOT NULL, + embedding BLOB NOT NULL, + created_at TEXT NOT NULL + );", + ) + .map_err(|e| EmbeddingError::Http(e.to_string())) +} + +fn ensure_phrase_memory_schema(conn: &rusqlite::Connection) -> Result<(), EmbeddingError> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS phrase_memory ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + source_phrase TEXT NOT NULL, + target_phrase TEXT NOT NULL, + source_language TEXT NOT NULL, + target_language TEXT NOT NULL, + author TEXT, + work TEXT, + domain TEXT, + tags TEXT, + notes TEXT, + chunk_id TEXT, + project_id TEXT REFERENCES projects(id), + embedding BLOB NOT NULL, + created_at TEXT NOT NULL + );", + ) + .map_err(|e| EmbeddingError::Http(e.to_string())) +} + // ── OpenAI response types ──────────────────────────────────────────── #[derive(Deserialize)] @@ -171,6 +208,7 @@ pub async fn vec_upsert_source_phrase( let path = db_path(&app)?; let conn = crate::vector::open_vec_connection(&path) .map_err(|e| EmbeddingError::Http(e.to_string()))?; + ensure_source_phrase_embeddings_schema(&conn)?; conn.execute( "INSERT OR REPLACE INTO source_phrase_embeddings \ @@ -191,6 +229,122 @@ pub struct PhraseMatchResult { pub distance: f64, } +#[derive(Debug, Serialize, Deserialize)] +pub struct PhraseMemoryEntryResult { + pub id: String, + pub workspace_id: String, + pub source_phrase: String, + pub target_phrase: String, + pub source_language: String, + pub target_language: String, + pub author: Option, + pub work: Option, + pub domain: Option, + pub tags: Option, + pub notes: Option, + pub chunk_id: Option, + pub project_id: Option, + pub created_at: String, +} + +#[tauri::command] +pub async fn vec_list_phrase_memory( + app: tauri::AppHandle, + workspace_id: String, +) -> Result, EmbeddingError> { + let path = db_path(&app)?; + let conn = crate::vector::open_vec_connection(&path) + .map_err(|e| EmbeddingError::Http(e.to_string()))?; + ensure_phrase_memory_schema(&conn)?; + + let mut stmt = conn + .prepare( + "SELECT id, workspace_id, source_phrase, target_phrase, source_language, target_language, \ + author, work, domain, tags, notes, chunk_id, project_id, created_at \ + FROM phrase_memory \ + WHERE workspace_id = ?1 \ + ORDER BY datetime(created_at) DESC, id DESC", + ) + .map_err(|e| EmbeddingError::Http(e.to_string()))?; + + let results: rusqlite::Result> = stmt + .query_map(rusqlite::params![workspace_id], |row| { + Ok(PhraseMemoryEntryResult { + id: row.get(0)?, + workspace_id: row.get(1)?, + source_phrase: row.get(2)?, + target_phrase: row.get(3)?, + source_language: row.get(4)?, + target_language: row.get(5)?, + author: row.get(6)?, + work: row.get(7)?, + domain: row.get(8)?, + tags: row.get(9)?, + notes: row.get(10)?, + chunk_id: row.get(11)?, + project_id: row.get(12)?, + created_at: row.get(13)?, + }) + }) + .map_err(|e| EmbeddingError::Http(e.to_string()))? + .collect(); + + results.map_err(|e| EmbeddingError::Http(e.to_string())) +} + +#[tauri::command] +pub async fn vec_delete_phrase_memory( + app: tauri::AppHandle, + workspace_id: String, + phrase_memory_id: String, +) -> Result { + let path = db_path(&app)?; + let conn = crate::vector::open_vec_connection(&path) + .map_err(|e| EmbeddingError::Http(e.to_string()))?; + ensure_phrase_memory_schema(&conn)?; + + let deleted = conn + .execute( + "DELETE FROM phrase_memory WHERE id = ?1 AND workspace_id = ?2", + rusqlite::params![phrase_memory_id, workspace_id], + ) + .map_err(|e| EmbeddingError::Http(e.to_string()))?; + + Ok(deleted as u32) +} + +#[tauri::command] +pub async fn vec_update_phrase_memory( + app: tauri::AppHandle, + workspace_id: String, + phrase_memory_id: String, + source_phrase: String, + target_phrase: String, + embedding: Vec, +) -> Result { + let path = db_path(&app)?; + let conn = crate::vector::open_vec_connection(&path) + .map_err(|e| EmbeddingError::Http(e.to_string()))?; + ensure_phrase_memory_schema(&conn)?; + + let updated = conn + .execute( + "UPDATE phrase_memory \ + SET source_phrase = ?1, target_phrase = ?2, embedding = ?3 \ + WHERE id = ?4 AND workspace_id = ?5", + rusqlite::params![ + source_phrase, + target_phrase, + floats_to_blob(&embedding), + phrase_memory_id, + workspace_id + ], + ) + .map_err(|e| EmbeddingError::Http(e.to_string()))?; + + Ok(updated as u32) +} + #[tauri::command] pub async fn vec_search_phrase_memory( app: tauri::AppHandle, @@ -202,6 +356,7 @@ pub async fn vec_search_phrase_memory( let path = db_path(&app)?; let conn = crate::vector::open_vec_connection(&path) .map_err(|e| EmbeddingError::Http(e.to_string()))?; + ensure_phrase_memory_schema(&conn)?; let blob = floats_to_blob(&query_embedding); @@ -266,6 +421,8 @@ pub async fn vec_save_locked_phrases( let path = db_path(&app)?; let conn = crate::vector::open_vec_connection(&path) .map_err(|e| EmbeddingError::Http(e.to_string()))?; + ensure_phrase_memory_schema(&conn)?; + ensure_source_phrase_embeddings_schema(&conn)?; let mut saved: u32 = 0; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0d415eb..ef2f526 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,10 +13,10 @@ "windows": [ { "title": "Glossa — Translation Pipeline", - "width": 1600, - "height": 1280, - "minWidth": 1200, - "minHeight": 900, + "width": 1920, + "height": 1080, + "minWidth": 1280, + "minHeight": 760, "resizable": true, "fullscreen": false } diff --git a/src/App.tsx b/src/App.tsx index 86f91cf..7f49c32 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -356,7 +356,7 @@ export default function App() { // ── Shell loading ──────────────────────────────────────────────────── if (!isLoaded) { return ( -
+
Caricamento...
); @@ -378,9 +378,9 @@ export default function App() { } return ( - + -
+
diff --git a/src/components/layout/PipelineSidebar.tsx b/src/components/layout/PipelineSidebar.tsx index df03c3f..7550316 100644 --- a/src/components/layout/PipelineSidebar.tsx +++ b/src/components/layout/PipelineSidebar.tsx @@ -19,10 +19,21 @@ import { Settings2, Square, Upload, - Zap, } from 'lucide-react'; -import { lazy, Suspense, useMemo, useState } from 'react'; +import { + lazy, + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type RefObject, +} from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { motion } from 'motion/react'; import { toast } from 'sonner'; @@ -35,7 +46,7 @@ import { useUiStore } from '../../stores/uiStore'; import { useWorkspaceStore } from '../../stores/workspaceStore'; import { estimatePipelineCost } from '../../utils/costEstimate'; import { CostBreakdownPanel } from '../pipeline/CostBadge'; -import { IconButton, SectionLabel } from '../ui'; +import { IconButton, SectionLabel, Tooltip } from '../ui'; import { exportTranslation, exportBilingual } from '../../services/fileService'; import type { ExportFormat } from '../document/ExportDialog'; import { DashboardSidebar } from './DashboardSidebar'; @@ -43,6 +54,83 @@ import { DashboardSidebar } from './DashboardSidebar'; const ExportDialog = lazy(() => import('../document/ExportDialog').then((m) => ({ default: m.ExportDialog })), ); + +const COST_PANEL_OFFSET = 12; +const COST_PANEL_WIDTH = 256; +const VIEWPORT_MARGIN = 12; + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function SidebarCostPanel({ + anchorRef, + estimate, + open, + onMouseEnter, + onMouseLeave, +}: { + anchorRef: RefObject; + estimate: ReturnType; + open: boolean; + onMouseEnter: () => void; + onMouseLeave: () => void; +}) { + const panelRef = useRef(null); + const [style, setStyle] = useState(null); + + const updatePosition = useCallback(() => { + if (!anchorRef.current) return; + const anchorRect = anchorRef.current.getBoundingClientRect(); + const panelHeight = panelRef.current?.offsetHeight ?? 220; + const left = Math.min( + anchorRect.right + COST_PANEL_OFFSET, + window.innerWidth - VIEWPORT_MARGIN - COST_PANEL_WIDTH, + ); + const top = clamp( + anchorRect.top + anchorRect.height / 2, + VIEWPORT_MARGIN + panelHeight / 2, + window.innerHeight - VIEWPORT_MARGIN - panelHeight / 2, + ); + setStyle({ + left, + top, + width: COST_PANEL_WIDTH, + transform: 'translateY(-50%)', + }); + }, [anchorRef]); + + useLayoutEffect(() => { + if (!open) return; + updatePosition(); + }, [estimate, open, updatePosition]); + + useEffect(() => { + if (!open) return; + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [open, updatePosition]); + + if (!open || typeof document === 'undefined') return null; + + return createPortal( +
+ +
, + document.body, + ); +} + interface PipelineSidebarProps { mode?: 'dashboard' | 'editor'; onRunPipeline?: () => void; @@ -100,6 +188,8 @@ export function PipelineSidebar({ // ── Local state ─────────────────────────────────────────────────── const [showCostPanel, setShowCostPanel] = useState(false); const [showExportDialog, setShowExportDialog] = useState(false); + const costButtonRef = useRef(null); + const costPanelCloseTimer = useRef(null); // ── Derived ─────────────────────────────────────────────────────── const isRunning = runStatus === 'running'; @@ -123,6 +213,32 @@ export function PipelineSidebar({ ); const activePipelineName = pipelines.find((p) => p.id === activePipelineId)?.name ?? null; + const openCostPanel = useCallback(() => { + if (costPanelCloseTimer.current !== null) { + window.clearTimeout(costPanelCloseTimer.current); + costPanelCloseTimer.current = null; + } + setShowCostPanel(true); + }, []); + + const scheduleCloseCostPanel = useCallback(() => { + if (costPanelCloseTimer.current !== null) { + window.clearTimeout(costPanelCloseTimer.current); + } + costPanelCloseTimer.current = window.setTimeout(() => { + setShowCostPanel(false); + costPanelCloseTimer.current = null; + }, 120); + }, []); + + useEffect(() => { + return () => { + if (costPanelCloseTimer.current !== null) { + window.clearTimeout(costPanelCloseTimer.current); + } + }; + }, []); + // ── Pipeline delete ─────────────────────────────────────────────── const handleDeletePipeline = async (pipelineId: string, pipelineName: string) => { const ok = await confirm({ @@ -238,11 +354,10 @@ export function PipelineSidebar({ ariaLabel={t('pipeline.modeTest')} ariaPressed={pipelineMode === 'test'} tooltipSide="right" - className={`h-11 w-11 ${ - pipelineMode === 'test' - ? 'border-editorial-accent bg-editorial-bg text-editorial-ink shadow-sm' - : 'bg-editorial-textbox' - }`} + className={`h-11 w-11 ${pipelineMode === 'test' + ? 'border-editorial-accent bg-editorial-bg text-editorial-ink shadow-sm' + : 'bg-editorial-textbox' + }`} > @@ -255,11 +370,10 @@ export function PipelineSidebar({ ariaLabel={t('pipeline.modeProduction')} ariaPressed={pipelineMode === 'production'} tooltipSide="right" - className={`h-11 w-11 ${ - pipelineMode === 'production' - ? 'border-editorial-accent bg-editorial-bg text-editorial-charcoal shadow-sm' - : 'bg-editorial-textbox' - }`} + className={`h-11 w-11 ${pipelineMode === 'production' + ? 'border-editorial-accent bg-editorial-bg text-editorial-charcoal shadow-sm' + : 'bg-editorial-textbox' + }`} > @@ -307,16 +421,18 @@ export function PipelineSidebar({ )} {costEstimate.stages.length > 0 && (
setShowCostPanel(true)} - onMouseLeave={() => setShowCostPanel(false)} + onMouseEnter={openCostPanel} + onMouseLeave={scheduleCloseCostPanel} > setShowCostPanel(true)} - onBlur={() => setShowCostPanel(false)} - title={t('cost.breakdown')} + onFocus={openCostPanel} + onBlur={scheduleCloseCostPanel} + title="" + ariaLabel={t('cost.breakdown')} tooltipSide="right" className="h-6 w-6 bg-editorial-bg p-0" > @@ -324,15 +440,13 @@ export function PipelineSidebar({
)} - {showCostPanel && costEstimate.stages.length > 0 && ( -
setShowCostPanel(true)} - onMouseLeave={() => setShowCostPanel(false)} - > - -
- )} + 0} + onMouseEnter={openCostPanel} + onMouseLeave={scheduleCloseCostPanel} + />
{hasDocument && ( @@ -354,16 +468,19 @@ export function PipelineSidebar({ > -
+
- {runChunkCount} -
+ }`} + > + {runChunkCount} +
+ {pipelines.length === 0 ? ( -
- - 1 - -
+ +
+ + 1 + +
+
) : (
{pipelines.map((pipeline, i) => { @@ -436,15 +552,17 @@ export function PipelineSidebar({ )} {pipelines.length > 1 && !isPipelineRunning && ( - + tooltipSide="right" + className="absolute -right-1 -top-1 z-10 hidden h-4 w-4 bg-editorial-bg p-0 group-hover:flex" + > - + )}
); diff --git a/src/components/layout/PipelineStrip.tsx b/src/components/layout/PipelineStrip.tsx index 680a8ee..4a8d4a0 100644 --- a/src/components/layout/PipelineStrip.tsx +++ b/src/components/layout/PipelineStrip.tsx @@ -46,11 +46,10 @@ export function PipelineStrip() { onClick={() => switchPipeline(pipeline.id)} title={pipeline.name} aria-label={pipeline.name} - className={`relative flex h-9 w-9 items-center justify-center rounded-[6px] text-xs font-black transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent ${ - isActive - ? 'bg-editorial-accent text-white' - : 'border border-editorial-border bg-editorial-textbox text-editorial-muted hover:border-editorial-accent/60 hover:text-editorial-accent' - }`} + className={`relative flex h-9 w-9 items-center justify-center rounded-[6px] text-xs font-black transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent ${isActive + ? 'bg-editorial-accent text-white' + : 'border border-editorial-border bg-editorial-textbox text-editorial-muted hover:border-editorial-accent/60 hover:text-editorial-accent' + }`} > {isPipelineRunning ? ( @@ -89,11 +88,10 @@ export function PipelineStrip() { onClick={() => setShowConfigDrawer(!showConfigDrawer)} title={t('pipeline.configurePipeline')} aria-label={t('pipeline.configurePipeline')} - className={`flex h-8 w-8 items-center justify-center rounded-full border transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent ${ - showConfigDrawer - ? 'border-editorial-accent bg-editorial-accent text-white' - : 'border-editorial-border bg-editorial-textbox text-editorial-muted hover:border-editorial-accent/60 hover:text-editorial-accent' - }`} + className={`flex h-8 w-8 items-center justify-center rounded-full border transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent ${showConfigDrawer + ? 'border-editorial-accent bg-editorial-accent text-white' + : 'border-editorial-border bg-editorial-textbox text-editorial-muted hover:border-editorial-accent/60 hover:text-editorial-accent' + }`} > diff --git a/src/components/library/LibraryPanel.tsx b/src/components/library/LibraryPanel.tsx index 5a2dc46..a57126d 100644 --- a/src/components/library/LibraryPanel.tsx +++ b/src/components/library/LibraryPanel.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { BookMarked, BookOpenText } from 'lucide-react'; +import { BookMarked, BookOpenText, Brain } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; @@ -7,14 +7,23 @@ import { useLibraryStore, type LibraryTab } from '../../stores/libraryStore'; import { useFocusTrap } from '../../hooks/useFocusTrap'; import { confirm } from '../../stores/confirmStore'; import { DictionariesTab } from './DictionariesTab'; +import { MemoriesTab } from './MemoriesTab'; import { PromptTemplatesTab } from './PromptTemplatesTab'; import { EditorialModalShell } from '../common'; +import { IconButton } from '../ui'; const TABS: { id: LibraryTab; labelKey: string }[] = [ { id: 'dictionaries', labelKey: 'library.tabDictionaries' }, { id: 'templates', labelKey: 'library.tabTemplates' }, + { id: 'memories', labelKey: 'library.tabMemories' }, ]; +function tabIcon(tab: LibraryTab) { + if (tab === 'dictionaries') return ; + if (tab === 'templates') return ; + return ; +} + export function LibraryPanel() { const { t } = useTranslation(); const { @@ -97,23 +106,21 @@ export function LibraryPanel() { >
-
+
{TABS.map((tab) => ( - + > + {tabIcon(tab.id)} + ))}
diff --git a/src/components/library/MemoriesTab.tsx b/src/components/library/MemoriesTab.tsx new file mode 100644 index 0000000..ad8212e --- /dev/null +++ b/src/components/library/MemoriesTab.tsx @@ -0,0 +1,307 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Brain, Check, Loader2, Pencil, RefreshCcw, Trash2, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { + deletePhraseMemoryEntry, + listPhraseMemoryEntries, + updatePhraseMemoryEntry, + type PhraseMemoryEntry, +} from '../../services/phraseMemoryService'; +import { useWorkspaceStore } from '../../stores/workspaceStore'; +import { confirm } from '../../stores/confirmStore'; +import { IconButton, SectionLabel } from '../ui'; +import type { Workspace } from '../../types'; + +export function MemoriesTab() { + const { t } = useTranslation(); + const activeWorkspace = useWorkspaceStore((s) => s.activeWorkspace); + const workspaces = useWorkspaceStore((s) => s.workspaces); + const [entries, setEntries] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [workspaceFilter, setWorkspaceFilter] = useState('all'); + const [editingId, setEditingId] = useState(null); + const [draftSource, setDraftSource] = useState(''); + const [draftTarget, setDraftTarget] = useState(''); + const [busyId, setBusyId] = useState(null); + + const loadEntries = useCallback(async () => { + if (workspaces.length === 0) { + setEntries([]); + return; + } + setIsLoading(true); + setEditingId(null); + try { + const targetWorkspaces = + workspaceFilter === 'all' + ? workspaces + : workspaces.filter((workspace) => workspace.id === workspaceFilter); + const results = await Promise.all( + targetWorkspaces.map((workspace) => listPhraseMemoryEntries(workspace.id)), + ); + setEntries( + results + .flat() + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()), + ); + } catch (err: unknown) { + toast.error(t('library.memoryLoadError'), { + description: err instanceof Error ? err.message : String(err), + }); + } finally { + setIsLoading(false); + } + }, [t, workspaceFilter, workspaces]); + + useEffect(() => { + void loadEntries(); + }, [loadEntries]); + + const startEdit = (entry: PhraseMemoryEntry) => { + setEditingId(entry.id); + setDraftSource(entry.sourcePhrase); + setDraftTarget(entry.targetPhrase); + }; + + const closeEdit = () => { + setEditingId(null); + setDraftSource(''); + setDraftTarget(''); + }; + + const handleSave = async (entry: PhraseMemoryEntry) => { + const workspace = workspaces.find((item) => item.id === entry.workspaceId) ?? activeWorkspace; + if (!workspace || !draftSource.trim() || !draftTarget.trim()) return; + setBusyId(entry.id); + try { + await updatePhraseMemoryEntry({ + workspaceId: entry.workspaceId, + phraseMemoryId: entry.id, + embeddingModel: workspace.embeddingModel, + sourcePhrase: draftSource, + targetPhrase: draftTarget, + }); + closeEdit(); + await loadEntries(); + toast.success(t('library.memoryUpdated')); + } catch (err: unknown) { + toast.error(t('library.memoryUpdateError'), { + description: err instanceof Error ? err.message : String(err), + }); + } finally { + setBusyId(null); + } + }; + + const handleDelete = async (entry: PhraseMemoryEntry) => { + const ok = await confirm({ + title: t('library.memoryDeleteTitle'), + message: t('library.memoryDeleteMessage'), + confirmLabel: t('common.delete'), + danger: true, + }); + if (!ok) return; + + setBusyId(entry.id); + try { + await deletePhraseMemoryEntry(entry.workspaceId, entry.id); + setEntries((current) => current.filter((item) => item.id !== entry.id)); + toast.success(t('library.memoryDeleted')); + } catch (err: unknown) { + toast.error(t('library.memoryDeleteError'), { + description: err instanceof Error ? err.message : String(err), + }); + } finally { + setBusyId(null); + } + }; + + return ( +
+
+
+ + + {t('library.memoriesCount', { count: entries.length })} + +
+
+ + void loadEntries()} + title={t('common.refresh')} + disabled={isLoading} + > + {isLoading ? : } + +
+
+ + {isLoading ? ( +
+ + {t('common.loading')} +
+ ) : entries.length === 0 ? ( +
+ +

+ {t('library.noMemories')} +

+
+ ) : ( +
+ {entries.map((entry) => { + const isEditing = editingId === entry.id; + const isBusy = busyId === entry.id; + return ( +
+
+
+

+ {entry.sourceLanguage} → {entry.targetLanguage} +

+

+ {workspaceName(entry.workspaceId, workspaces)} · {formatDate(entry.createdAt)} +

+
+
+ {isEditing ? ( + <> + void handleSave(entry)} + title={t('common.save')} + disabled={isBusy || !draftSource.trim() || !draftTarget.trim()} + > + {isBusy ? : } + + + + + + ) : ( + <> + startEdit(entry)} + title={t('common.edit')} + disabled={busyId !== null} + > + + + void handleDelete(entry)} + title={t('common.delete')} + disabled={busyId !== null} + > + {isBusy ? : } + + + )} +
+
+ + {isEditing ? ( +
+ + +
+ ) : ( +
+
+ {entry.sourcePhrase} +
+
+ {entry.targetPhrase} +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} + +function MemoryTextarea({ + label, + value, + onChange, + autoFocus = false, +}: { + label: string; + value: string; + onChange: (value: string) => void; + autoFocus?: boolean; +}) { + return ( +