diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 97f10f2..2ffcce5 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -842,18 +842,17 @@ const PreviewWorkspace = ({ ); }; -const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => { +const TagsPanel = ({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) => { const [editingId, setEditingId] = useState(null); const [draftLabel, setDraftLabel] = useState(''); const [draftColor, setDraftColor] = useState(''); - const [error, setError] = useState(null); const [saving, setSaving] = useState(false); + const [deletingId, setDeletingId] = useState(null); const startEdit = useCallback((tag) => { setEditingId(tag.id); setDraftLabel(tag.label || ''); setDraftColor(tag.color || ''); - setError(null); }, []); const cancelEdit = useCallback(() => { @@ -861,7 +860,6 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => { setDraftLabel(''); setDraftColor(''); setSaving(false); - setError(null); }, []); const colorPickerValue = useMemo(() => { @@ -880,19 +878,18 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => { const trimmedLabel = draftLabel.trim(); if (!trimmedLabel) { - setError('Tag label cannot be empty.'); + onNotify?.('Tag label cannot be empty.', 'error'); return; } const trimmedColor = draftColor.trim(); const colorPattern = /^#([0-9a-fA-F]{6})$/; if (trimmedColor && !colorPattern.test(trimmedColor)) { - setError('Colors must use the #RRGGBB format.'); + onNotify?.('Colors must use the #RRGGBB format.', 'error'); return; } setSaving(true); - setError(null); try { await onUpdateTag(editingId, { label: trimmedLabel, @@ -901,11 +898,11 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => { cancelEdit(); } catch (updateError) { const message = updateError?.message || 'Failed to update tag.'; - setError(message); + onNotify?.(message, 'error'); } finally { setSaving(false); } - }, [editingId, draftLabel, draftColor, onUpdateTag, cancelEdit]); + }, [editingId, draftLabel, draftColor, onUpdateTag, cancelEdit, onNotify]); const handleKeyDown = useCallback( (event) => { @@ -920,6 +917,28 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => { [handleSave, cancelEdit], ); + const handleDelete = useCallback( + async (tag) => { + if (!tag?.id || typeof onDeleteTag !== 'function') { + return; + } + + setDeletingId(tag.id); + try { + await onDeleteTag(tag.id); + if (editingId === tag.id) { + cancelEdit(); + } + } catch (deleteError) { + const message = deleteError?.message || 'Failed to delete tag.'; + onNotify?.(message, 'error'); + } finally { + setDeletingId(null); + } + }, + [onDeleteTag, editingId, cancelEdit, onNotify], + ); + return (
@@ -928,17 +947,17 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
{tags.length} total
-
- {error && ( -
- {error} -
- )} {tags.length === 0 ? (
No tags created yet.
) : ( @@ -968,7 +987,7 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => { value={draftLabel} onChange={(event) => setDraftLabel(event.target.value)} onKeyDown={handleKeyDown} - disabled={saving} + disabled={saving || deletingId === tag.id} autoFocus /> ) : ( @@ -988,7 +1007,7 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => { className="tags-table__color-picker" value={colorPickerValue} onChange={(event) => setDraftColor(event.target.value)} - disabled={saving} + disabled={saving || deletingId === tag.id} aria-label="Pick tag color" /> {draftColor && ( @@ -996,7 +1015,7 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => { type="button" className="secondary" onClick={() => setDraftColor('')} - disabled={saving} + disabled={saving || deletingId === tag.id} > Clear @@ -1020,7 +1039,7 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => { type="button" className="secondary" onClick={handleSave} - disabled={saving} + disabled={saving || deletingId === tag.id} > Save @@ -1028,19 +1047,38 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => { type="button" className="secondary" onClick={cancelEdit} - disabled={saving} + disabled={saving || deletingId === tag.id} > Cancel +
) : ( - +
+ + +
)} @@ -2024,6 +2062,76 @@ const AppLayout = () => { [api, refreshTags, notifyApiError], ); + const handleTagDelete = useCallback( + async (tagId) => { + if (!tagId) { + throw new Error('Missing tag identifier.'); + } + + try { + await api.delete(`/tags/${tagId}`); + setActiveTagFilters((prev) => prev.filter((id) => id !== tagId)); + + const stripTagFromDoc = (doc) => { + if (!doc || !Array.isArray(doc.tags)) { + return doc; + } + const nextTags = doc.tags.filter((tag) => tag.id !== tagId); + if (nextTags.length === doc.tags.length) { + return doc; + } + return { ...doc, tags: nextTags }; + }; + + setDocuments((prev) => prev.map((doc) => stripTagFromDoc(doc))); + setSearchResults((prev) => + Array.isArray(prev) ? prev.map((doc) => stripTagFromDoc(doc)) : prev, + ); + + setFolderContents((prev) => { + if (!prev.size) { + return prev; + } + let changed = false; + const next = new Map(); + prev.forEach((contents, key) => { + if (!Array.isArray(contents?.documents)) { + next.set(key, contents); + return; + } + const updatedDocs = contents.documents.map((doc) => stripTagFromDoc(doc)); + const mutated = updatedDocs.some((doc, index) => doc !== contents.documents[index]); + if (mutated) { + changed = true; + next.set(key, { ...contents, documents: updatedDocs }); + } else { + next.set(key, contents); + } + }); + return changed ? next : prev; + }); + + await refreshTags(); + setStatusMessage('Tag deleted.', 'success'); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to delete tag.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [ + api, + refreshTags, + notifyApiError, + setStatusMessage, + setDocuments, + setSearchResults, + setFolderContents, + setActiveTagFilters, + ], + ); + const loadFolder = useCallback( async (folderId, { showLoading = true } = {}) => { const targetId = folderId || 'root'; @@ -4275,6 +4383,7 @@ const AppLayout = () => { token, appStatus, status, + setStatusMessage, dropOverlayState, handleBulkReanalyze, handleLogout, @@ -4283,6 +4392,7 @@ const AppLayout = () => { refreshTags, handleTagUpdate, handleTagCreate, + handleTagDelete, handleDocumentTagAttach, previewActive, previewWorkspaceDocument, @@ -4300,6 +4410,7 @@ const AppLayout = () => { token, appStatus, status, + setStatusMessage, dropOverlayState, handleBulkReanalyze, handleLogout, @@ -4308,6 +4419,7 @@ const AppLayout = () => { refreshTags, handleTagUpdate, handleTagCreate, + handleTagDelete, handleDocumentTagAttach, previewActive, previewWorkspaceDocument, @@ -4546,10 +4658,23 @@ const LoginRoute = () => { }; const TagsRoute = () => { - const { sidebarProps, tags, refreshTags, handleTagUpdate } = useAppShell(); + const { + sidebarProps, + tags, + refreshTags, + handleTagUpdate, + handleTagDelete, + setStatusMessage, + } = useAppShell(); return ( - + ); }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index bf9066b..fbcb57a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -13,6 +13,8 @@ --overlay-backdrop: rgba(30, 33, 39, 0.45); --overlay-shadow: rgba(30, 33, 39, 0.18); + --app-bar-background: var(--surface-subtle); + /* Accent (primary action, selection, focus) */ --accent: #3f6ad8; --accent-hover: #365cb9; @@ -209,7 +211,7 @@ button.icon-button.ghost:hover:not([disabled]) { flex-direction: column; gap: 0.4rem; padding: 0.6rem 1.5rem 0.4rem; - background: var(--surface); + background: var(--app-bar-background); border-bottom: 1px solid var(--border); position: sticky; top: 0; @@ -422,6 +424,12 @@ button.icon-button.ghost:hover:not([disabled]) { gap: 0.4rem; } +.tags-table__row-actions { + display: flex; + justify-content: flex-end; + gap: 0.4rem; +} + .preview-workspace__message { color: var(--muted); font-size: 0.95rem;