tag deletion

This commit is contained in:
2025-10-14 23:35:29 +02:00
parent 60939306e7
commit 3db221cce4
2 changed files with 163 additions and 30 deletions
+154 -29
View File
@@ -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 (
<section className="tags-panel column">
<div className="column-header">
@@ -928,17 +947,17 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
<div className="column-subtitle">{tags.length} total</div>
</div>
<div className="header-actions">
<button className="secondary" type="button" onClick={onRefresh} disabled={saving}>
<button
className="secondary"
type="button"
onClick={onRefresh}
disabled={saving || Boolean(deletingId)}
>
Refresh
</button>
</div>
</div>
<div className="column-body tags-panel__body">
{error && (
<div className="tags-panel__error" role="alert">
{error}
</div>
)}
{tags.length === 0 ? (
<div className="empty-state">No tags created yet.</div>
) : (
@@ -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
</button>
@@ -1020,7 +1039,7 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
type="button"
className="secondary"
onClick={handleSave}
disabled={saving}
disabled={saving || deletingId === tag.id}
>
Save
</button>
@@ -1028,19 +1047,38 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
type="button"
className="secondary"
onClick={cancelEdit}
disabled={saving}
disabled={saving || deletingId === tag.id}
>
Cancel
</button>
<button
type="button"
className="danger"
onClick={() => handleDelete(tag)}
disabled={deletingId === tag.id}
>
Delete
</button>
</div>
) : (
<button
type="button"
className="secondary"
onClick={() => startEdit(tag)}
>
Edit
</button>
<div className="tags-table__row-actions">
<button
type="button"
className="secondary"
onClick={() => startEdit(tag)}
disabled={deletingId === tag.id}
>
Edit
</button>
<button
type="button"
className="danger"
onClick={() => handleDelete(tag)}
disabled={deletingId === tag.id}
>
Delete
</button>
</div>
)}
</td>
</tr>
@@ -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 (
<MainLayout sidebarProps={sidebarProps} className="tags-main">
<TagsPanel tags={tags} onRefresh={refreshTags} onUpdateTag={handleTagUpdate} />
<TagsPanel
tags={tags}
onRefresh={refreshTags}
onUpdateTag={handleTagUpdate}
onDeleteTag={handleTagDelete}
onNotify={setStatusMessage}
/>
</MainLayout>
);
};
+9 -1
View File
@@ -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;