diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx
index 0965ee4..04df63a 100644
--- a/frontend/src/documents/DocumentsTable.jsx
+++ b/frontend/src/documents/DocumentsTable.jsx
@@ -275,7 +275,13 @@ const DocumentsTable = ({
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
}`}
id={`folder-row-${folder.id}`}
- onClick={() => onFolderSelect(folder.id)}
+ onClick={() => {
+ onFolderSelect(folder.id);
+ if (scrollRef.current) {
+ scrollRef.current.focus({ preventScroll: true });
+ }
+ onFocusedRowChange?.(`folder:${folder.id}`);
+ }}
onDragOver={(event) => onFolderDragOver(event, folder.id)}
onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop(event, folder.id)}
diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx
index 63a81c6..fbbb47c 100644
--- a/frontend/src/index.jsx
+++ b/frontend/src/index.jsx
@@ -24,7 +24,7 @@ import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './as
import useApiError from './hooks/useApiError';
import SkeuomorphicWorkspace from './skeuomorphic_ws';
import { DownloadIcon, EditIcon } from './ui/icons';
-import { getTagColorStyle, HEX_COLOR_PATTERN } from './utils/colors';
+import { generateRandomTagColor, getTagColorStyle, HEX_COLOR_PATTERN } from './utils/colors';
import Sidebar from './sidebar/Sidebar';
import DocumentsTable, { FilterBar } from './documents/DocumentsTable';
@@ -285,7 +285,6 @@ const PreviewStack = ({
const DetailPanel = ({
selectedDocuments = [],
- detailMap = new Map(),
tags = [],
tagLookupById = new Map(),
tagLookupByLabel = new Map(),
@@ -306,10 +305,8 @@ const DetailPanel = ({
ensureAssetUrl = null,
getDocumentAsset = () => null,
}) => {
- const lookup = detailMap && typeof detailMap.get === 'function' ? detailMap : new Map();
const selectedCount = selectedDocuments.length;
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
- const detail = singleDoc ? lookup.get(singleDoc.id) || null : null;
const [titleEditDocId, setTitleEditDocId] = useState(null);
const [titleDraft, setTitleDraft] = useState('');
@@ -465,26 +462,31 @@ const DetailPanel = ({
const stackTotalSizeBytes = useMemo(() => {
if (!stackPreviews.length) return 0;
+ const byId = new Map(selectedDocuments.map((doc) => [doc.id, doc]));
return stackPreviews.reduce((sum, item) => {
- const detailEntry = lookup.get(item.id);
- const bytes = detailEntry?.current_version?.size_bytes || 0;
+ const source = byId.get(item.id);
+ const bytes = source?.current_version?.size_bytes;
return sum + (typeof bytes === 'number' ? bytes : 0);
}, 0);
- }, [stackPreviews, lookup]);
+ }, [stackPreviews, selectedDocuments]);
const renderSingle = () => {
if (!singleDoc) {
return
Select a document to view metadata, tags and actions.
;
}
- if (!detail) {
- return Loading details…
;
- }
const displayName = singleDoc.title || singleDoc.original_name;
const downloadHref = singleDoc.current_version?.download_path
? resolveApiPath(singleDoc.current_version.download_path)
: null;
const isEditingTitle = titleEditDocId === singleDoc.id;
+ const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0;
+ const issuedAt = singleDoc.issued_at
+ ? new Date(singleDoc.issued_at).toLocaleString()
+ : '—';
+ const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
+ const metadata =
+ singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
return (
<>
@@ -555,18 +557,13 @@ const DetailPanel = ({
Size:{' '}
- {detail.current_version
- ? `${(detail.current_version.size_bytes / 1024).toFixed(1)} KB`
- : '—'}
+ {sizeBytes ? `${(sizeBytes / 1024).toFixed(1)} KB` : '—'}
Type: {singleDoc.content_type || 'Unknown'}
- Issued:{' '}
- {detail.document.issued_at
- ? new Date(detail.document.issued_at).toLocaleString()
- : '—'}
+ Issued: {issuedAt}
Original filename:{' '}
@@ -607,8 +604,8 @@ const DetailPanel = ({
Tags
- {detail.document.tags?.length ? (
- detail.document.tags.map((tag) => {
+ {tagsForDoc.length ? (
+ tagsForDoc.map((tag) => {
const colorSource = tag?.color || tagLookupById.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
return (
@@ -643,13 +640,12 @@ const DetailPanel = ({
- {detail.document.metadata &&
- Object.keys(detail.document.metadata).length > 0 && (
-
-
Metadata
-
{JSON.stringify(detail.document.metadata, null, 2)}
-
- )}
+ {metadata && (
+
+
Metadata
+
{JSON.stringify(metadata, null, 2)}
+
+ )}
>
);
};
@@ -685,48 +681,37 @@ const DetailPanel = ({
{commonTags.length > 0 && (
- {commonTags.map((label) => {
- const key = typeof label === 'string' ? label.toLowerCase() : '';
- const tagInfo = key ? tagLookupByLabel.get(key) : null;
- const style = getTagColorStyle(tagInfo?.color);
- return (
-
- {label}
-
- );
- })}
+ Bulk tag operations
+
+
)}
-
-
-
-
@@ -850,10 +831,10 @@ const PreviewWorkspace = ({
/>
)}
- {detail?.document?.metadata && Object.keys(detail.document.metadata).length > 0 && (
+ {metadata && (
Metadata
- {JSON.stringify(detail.document.metadata, null, 2)}
+ {JSON.stringify(metadata, null, 2)}
)}
@@ -1133,8 +1114,6 @@ const AppLayout = () => {
const [focusedRowKey, setFocusedRowKey] = useState(() =>
routeDocumentId ? `document:${routeDocumentId}` : null,
);
- const [documentDetails, setDocumentDetails] = useState(() => new Map());
- const documentDetailsRef = useRef(documentDetails);
const tokenRef = useRef(token);
const refreshPromiseRef = useRef(null);
const breadcrumbFetchRef = useRef(new Set());
@@ -1240,46 +1219,6 @@ const AppLayout = () => {
return { ...doc, current_version: updatedCurrentVersion };
};
- const mergeAssetIntoDetail = (detail, assetData) => {
- if (!detail) return detail;
-
- const nextDocument = detail.document
- ? mergeAssetIntoDocument(detail.document, assetData)
- : detail.document;
-
- let nextAssets = detail.assets;
- let assetsChanged = false;
-
- if (Array.isArray(detail.assets)) {
- const index = detail.assets.findIndex((item) => item?.id === assetData.id);
- if (index >= 0) {
- const existing = detail.assets[index];
- if (!isAssetEquivalent(existing, assetData)) {
- const copy = detail.assets.slice();
- copy[index] = { ...existing, ...assetData };
- nextAssets = copy;
- assetsChanged = true;
- }
- } else {
- nextAssets = detail.assets.concat({ ...assetData });
- assetsChanged = true;
- }
- } else {
- nextAssets = [{ ...assetData }];
- assetsChanged = true;
- }
-
- if (!assetsChanged && nextDocument === detail.document) {
- return detail;
- }
-
- return {
- ...detail,
- document: nextDocument,
- assets: assetsChanged ? nextAssets : detail.assets,
- };
- };
-
const bootstrapInitializedRef = useRef(false);
const selectionInitializedRef = useRef(false);
const dragCounterRef = useRef(0);
@@ -1302,11 +1241,6 @@ const AppLayout = () => {
selectionAnchorRef.current = null;
setDraggedDocumentIds([]);
setDraggedFolderId(null);
- setDocumentDetails(() => {
- const next = new Map();
- documentDetailsRef.current = next;
- return next;
- });
setSearchResults(null);
setTags([]);
setSearchQuery('');
@@ -1382,10 +1316,6 @@ const AppLayout = () => {
}
}, [appStatus, resetWorkspaceState]);
- useEffect(() => {
- documentDetailsRef.current = documentDetails;
- }, [documentDetails]);
-
useEffect(() => {
tokenRef.current = token;
}, [token]);
@@ -2032,38 +1962,6 @@ const AppLayout = () => {
setDraggedFolderId(null);
}, [setDraggedFolderId]);
- const ensureDocumentDetail = useCallback(
- async (documentId, { force = false } = {}) => {
- if (!documentId) return null;
- const cached = documentDetailsRef.current.get(documentId);
- if (!force && cached) {
- return cached;
- }
-
- const { data } = await api.get(`/documents/${documentId}`);
- const hydratedDetail = assetManager.hydrateDetail(data);
- const hydratedDocument = hydratedDetail?.document || data.document;
-
- setDocumentDetails((prev) => {
- const next = new Map(prev);
- next.set(documentId, hydratedDetail);
- documentDetailsRef.current = next;
- return next;
- });
-
- setDocuments((prev) =>
- prev.map((doc) => (doc.id === documentId ? hydratedDocument : doc)),
- );
-
- setSearchResults((prev) =>
- prev ? prev.map((doc) => (doc.id === documentId ? hydratedDocument : doc)) : null,
- );
-
- return hydratedDetail;
- },
- [assetManager, setDocumentDetails, setDocuments, setSearchResults],
- );
-
const refreshTags = useCallback(async () => {
try {
const { data } = await api.get('/tags');
@@ -2111,7 +2009,7 @@ const AppLayout = () => {
if (!trimmed) {
throw new Error('Tag label is required.');
}
- const payload = { label: trimmed, color: color || null };
+ const payload = { label: trimmed, color: color || generateRandomTagColor() };
try {
await api.post('/tags', payload);
await refreshTags();
@@ -2139,18 +2037,15 @@ const AppLayout = () => {
console.warn('Failed to refresh root folder tree', error);
}
}
- const nextSelectedId = applySelectedFolder(targetId, contents);
+ applySelectedFolder(targetId, contents);
setSearchResults(null);
- if (nextSelectedId) {
- await ensureDocumentDetail(nextSelectedId, { force: true });
- }
} catch (error) {
notifyApiError(error, 'Failed to load folder contents.');
} finally {
if (showLoading) setLoading(false);
}
},
- [ensureFolderData, applySelectedFolder, ensureDocumentDetail, notifyApiError],
+ [ensureFolderData, applySelectedFolder, notifyApiError],
);
useEffect(() => {
@@ -2236,16 +2131,13 @@ const AppLayout = () => {
setLoading(true);
try {
const contents = await ensureFolderData(selectedFolder, { force: true });
- const nextSelectedId = applySelectedFolder(selectedFolder, contents);
- if (nextSelectedId) {
- await ensureDocumentDetail(nextSelectedId, { force: true });
- }
+ applySelectedFolder(selectedFolder, contents);
} catch (error) {
notifyApiError(error, 'Failed to refresh folder.');
} finally {
setLoading(false);
}
- }, [selectedFolder, ensureFolderData, applySelectedFolder, ensureDocumentDetail, notifyApiError]);
+ }, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]);
useEffect(() => {
if (appStatus !== 'authenticated') {
@@ -2401,9 +2293,7 @@ const AppLayout = () => {
action,
});
- await Promise.all(
- selectedDocumentIds.map((id) => ensureDocumentDetail(id, { force: true }).catch(() => null)),
- );
+ await refreshCurrentFolder();
return {
ok: true,
@@ -2425,7 +2315,7 @@ const AppLayout = () => {
tags,
api,
refreshTags,
- ensureDocumentDetail,
+ refreshCurrentFolder,
notifyApiError,
setLoading,
],
@@ -2673,21 +2563,6 @@ const AppLayout = () => {
: prev,
);
- setDocumentDetails((prev) => {
- if (!prev.has(documentId)) {
- return prev;
- }
- const current = prev.get(documentId);
- const updated = mergeAssetIntoDetail(current, entry);
- if (updated === current) {
- return prev;
- }
- const next = new Map(prev);
- next.set(documentId, updated);
- documentDetailsRef.current = next;
- return next;
- });
-
return entry;
} catch (error) {
notifyApiError(error, 'Unable to refresh document asset.');
@@ -2698,7 +2573,6 @@ const AppLayout = () => {
assetManager,
setDocuments,
setSearchResults,
- setDocumentDetails,
notifyApiError,
],
);
@@ -2968,12 +2842,6 @@ const AppLayout = () => {
'success',
);
- setDocumentDetails((prev) => {
- const next = new Map(prev);
- unique.forEach((id) => next.delete(id));
- return next;
- });
-
await refreshCurrentFolder();
if (targetFolderId && targetFolderId !== selectedFolder) {
await ensureFolderData(targetFolderId, { force: true });
@@ -3020,7 +2888,7 @@ const AppLayout = () => {
params: { force: true },
});
setStatusMessage('Document re-analysis queued.', 'info');
- await ensureDocumentDetail(documentId, { force: true });
+ await refreshCurrentFolder();
} catch (error) {
const message =
error.response?.data?.error || 'Failed to request thumbnail generation.';
@@ -3029,7 +2897,7 @@ const AppLayout = () => {
setLoading(false);
}
},
- [token, ensureDocumentDetail, notifyApiError, setStatusMessage],
+ [token, refreshCurrentFolder, notifyApiError, setStatusMessage],
);
const openDocumentPreview = useCallback(
@@ -3038,46 +2906,34 @@ const AppLayout = () => {
setPreviewDocumentId(documentId);
setPreviewDocumentLoading(true);
try {
- const detailData = await ensureDocumentDetail(documentId, { force: false });
+ const pool = searchResults ?? documents;
+ const doc = pool.find((item) => item.id === documentId);
+ if (!doc) {
+ throw new Error('Document metadata unavailable.');
+ }
- const previewAsset = getAssetFromVersion(
- detailData?.document?.current_version || null,
- 'preview',
- );
+ const currentVersion = doc.current_version || null;
+ const previewAsset = getAssetFromVersion(currentVersion, 'preview');
+ const thumbnailAsset = getAssetFromVersion(currentVersion, 'thumbnail');
- if (previewAsset) {
- const previewExpiresAt =
- typeof previewAsset.expiresAt === 'number' ? previewAsset.expiresAt : null;
- const previewNeedsRefresh =
- !previewAsset.url || (previewExpiresAt && previewExpiresAt <= Date.now());
- if (previewNeedsRefresh) {
+ const refreshAssetIfNeeded = async (asset) => {
+ if (!asset?.id) {
+ return;
+ }
+ const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
+ const shouldForce = Boolean(asset.url && expiresAt && expiresAt <= Date.now());
+ if (!asset.url || shouldForce) {
try {
- await ensureAssetUrl(documentId, previewAsset, {
- force: Boolean(previewExpiresAt && previewExpiresAt <= Date.now()),
- });
+ await ensureAssetUrl(documentId, asset, { force: shouldForce || !asset.url });
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
- }
+ };
- const thumbnailAsset = getAssetFromVersion(
- detailData?.document?.current_version || null,
- 'thumbnail',
- );
-
- if (thumbnailAsset) {
- const thumbExpiresAt =
- typeof thumbnailAsset.expiresAt === 'number' ? thumbnailAsset.expiresAt : null;
- const thumbNeedsRefresh =
- !thumbnailAsset.url || (thumbExpiresAt && thumbExpiresAt <= Date.now());
- if (thumbNeedsRefresh) {
- ensureAssetUrl(documentId, thumbnailAsset, {
- force: Boolean(thumbExpiresAt && thumbExpiresAt <= Date.now()),
- }).catch(() => {});
- }
- }
+ await refreshAssetIfNeeded(previewAsset);
+ refreshAssetIfNeeded(thumbnailAsset);
await ensurePreviewUrl(documentId, { force: false });
setActivePreviewId(documentId);
@@ -3091,7 +2947,14 @@ const AppLayout = () => {
setPreviewDocumentLoading(false);
}
},
- [ensureDocumentDetail, ensurePreviewUrl, ensureAssetUrl, navigate, notifyApiError],
+ [
+ documents,
+ searchResults,
+ ensurePreviewUrl,
+ ensureAssetUrl,
+ navigate,
+ notifyApiError,
+ ],
);
const handleDocumentListFocus = useCallback(() => {
@@ -3301,8 +3164,16 @@ const AppLayout = () => {
const hydratePreview = async () => {
try {
- const detail = await ensureDocumentDetail(routeDocumentId, { force: true });
- const doc = detail?.document || documentDetailsRef.current.get(routeDocumentId)?.document;
+ const pool = searchResults ?? documents;
+ let doc = pool.find((item) => item.id === routeDocumentId) || null;
+
+ if (!doc) {
+ const { data } = await api.get(`/documents/${routeDocumentId}`);
+ const hydratedDetail = assetManager.hydrateDetail(data);
+ const fetched = hydratedDetail?.document || data.document || data;
+ doc = fetched ? assetManager.hydrateDocument(fetched) : null;
+ }
+
const targetFolder = doc?.folder_id || routeFolderId || 'root';
if (targetFolder && targetFolder !== selectedFolder) {
await loadFolder(targetFolder, { showLoading: false });
@@ -3325,12 +3196,15 @@ const AppLayout = () => {
}, [
routeDocumentId,
routeFolderId,
- ensureDocumentDetail,
+ api,
+ assetManager,
selectedFolder,
loadFolder,
openDocumentPreview,
previewDocumentId,
closeDocumentPreview,
+ documents,
+ searchResults,
notifyApiError,
]);
@@ -3346,14 +3220,7 @@ const AppLayout = () => {
try {
const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
const hydratedDetail = assetManager.hydrateDetail(data);
- const hydratedDocument = hydratedDetail?.document || data.document;
-
- setDocumentDetails((prev) => {
- const next = new Map(prev);
- next.set(documentId, hydratedDetail);
- documentDetailsRef.current = next;
- return next;
- });
+ const hydratedDocument = hydratedDetail?.document || data.document || data;
setDocuments((prev) =>
prev.map((doc) => {
@@ -3390,7 +3257,11 @@ const AppLayout = () => {
next.set(key, {
...contents,
documents: contents.documents.map((doc) =>
- doc.id === documentId ? { ...doc, title: trimmed } : doc,
+ doc.id === documentId
+ ? hydratedDocument
+ ? { ...doc, ...hydratedDocument }
+ : { ...doc, title: trimmed }
+ : doc,
),
});
} else {
@@ -3421,16 +3292,12 @@ const AppLayout = () => {
try {
await api.delete(`/documents/${documentId}/tags/${tagId}`);
setStatusMessage('Tag removed.', 'success');
- await Promise.all([
- refreshCurrentFolder(),
- refreshTags(),
- ensureDocumentDetail(documentId, { force: true }),
- ]);
+ await Promise.all([refreshCurrentFolder(), refreshTags()]);
} catch (error) {
notifyApiError(error, 'Failed to remove tag.');
}
},
- [ensureDocumentDetail, refreshCurrentFolder, refreshTags, notifyApiError, setStatusMessage],
+ [refreshCurrentFolder, refreshTags, notifyApiError, setStatusMessage],
);
const handleTagAdd = useCallback(
@@ -3438,17 +3305,17 @@ const AppLayout = () => {
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
try {
if (!tag) {
- const { data } = await api.post('/tags', { label, color: null });
+ const { data } = await api.post('/tags', {
+ label,
+ color: generateRandomTagColor(),
+ });
tag = data;
await refreshTags();
}
await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] });
setStatusMessage('Tag assigned.', 'success');
input.value = '';
- await Promise.all([
- refreshCurrentFolder(),
- ensureDocumentDetail(document.id, { force: true }),
- ]);
+ await refreshCurrentFolder();
} catch (error) {
notifyApiError(error, 'Failed to assign tag.');
}
@@ -3457,7 +3324,6 @@ const AppLayout = () => {
tags,
refreshTags,
refreshCurrentFolder,
- ensureDocumentDetail,
notifyApiError,
setStatusMessage,
],
@@ -3472,10 +3338,7 @@ const AppLayout = () => {
try {
await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] });
setStatusMessage('Tag assigned.', 'success');
- await Promise.all([
- refreshCurrentFolder(),
- ensureDocumentDetail(documentId, { force: true }),
- ]);
+ await refreshCurrentFolder();
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to assign tag.';
@@ -3483,7 +3346,7 @@ const AppLayout = () => {
return false;
}
},
- [api, refreshCurrentFolder, ensureDocumentDetail, notifyApiError, setStatusMessage],
+ [api, refreshCurrentFolder, notifyApiError, setStatusMessage],
);
const handleFolderDelete = useCallback(
@@ -3532,10 +3395,7 @@ const AppLayout = () => {
const parentId = node?.parentId || 'root';
setSelectedFolder(parentId);
const parentContents = await ensureFolderData(parentId, { force: true });
- const nextSelectedId = applySelectedFolder(parentId, parentContents);
- if (nextSelectedId) {
- await ensureDocumentDetail(nextSelectedId, { force: true });
- }
+ applySelectedFolder(parentId, parentContents);
} else if (selectedFolder !== 'root') {
await ensureFolderData(selectedFolder, { force: true });
}
@@ -3553,7 +3413,6 @@ const AppLayout = () => {
selectedFolder,
folderNodes,
applySelectedFolder,
- ensureDocumentDetail,
notifyApiError,
setStatusMessage,
],
@@ -3750,10 +3609,7 @@ const AppLayout = () => {
selectionAnchorRef.current = targetId;
- if (!cancelled && targetId) {
- const cached = documentDetailsRef.current.get(targetId);
- await ensureDocumentDetail(targetId, { force: !cached });
- }
+ // rely on hydrated search results; assets refresh on demand
} catch (error) {
if (cancelled) return;
notifyApiError(error, 'Search failed. Please try again.');
@@ -3778,7 +3634,6 @@ const AppLayout = () => {
searchQuery,
activeTagFilters,
selectedFolder,
- ensureDocumentDetail,
notifyApiError,
assetManager,
]);
@@ -4048,11 +3903,6 @@ const AppLayout = () => {
: 'root';
}, [folderOptions, selectedFolder]);
- useEffect(() => {
- if (!focusedDocumentId) return;
- ensureDocumentDetail(focusedDocumentId, { force: false }).catch(() => {});
- }, [focusedDocumentId, ensureDocumentDetail]);
-
const selectedDocument = useMemo(() => {
if (!focusedDocumentId) {
return null;
@@ -4061,10 +3911,6 @@ const AppLayout = () => {
return list.find((doc) => doc.id === focusedDocumentId) || null;
}, [searchResults, documents, focusedDocumentId]);
- const selectedDetail = selectedDocument
- ? documentDetails.get(selectedDocument.id)
- : null;
-
const documentLookup = useMemo(() => {
const map = new Map();
documents.forEach((doc) => {
@@ -4090,7 +3936,7 @@ const AppLayout = () => {
return;
}
- const doc = documentLookup.get(documentId) || documentDetails.get(documentId)?.document || null;
+ const doc = documentLookup.get(documentId) || null;
const label = doc?.title || doc?.original_name || 'this document';
const confirmed = window.confirm(`Delete "${label}"? This action cannot be undone.`);
@@ -4106,16 +3952,6 @@ const AppLayout = () => {
setSearchResults((prev) => (prev ? prev.filter((item) => item.id !== documentId) : null));
- setDocumentDetails((prev) => {
- if (!prev.has(documentId)) {
- return prev;
- }
- const next = new Map(prev);
- next.delete(documentId);
- documentDetailsRef.current = next;
- return next;
- });
-
setFolderContents((prev) => {
let changed = false;
const next = new Map();
@@ -4166,11 +4002,9 @@ const AppLayout = () => {
api,
token,
documentLookup,
- documentDetails,
setStatusMessage,
setDocuments,
setSearchResults,
- setDocumentDetails,
setFolderContents,
setPreviewEntries,
previewInflightRef,
@@ -4193,50 +4027,18 @@ const AppLayout = () => {
};
selectionOrder.forEach((id) => {
- const doc = documentLookup.get(id) || documentDetails.get(id)?.document || null;
+ const doc = documentLookup.get(id) || null;
pushDoc(doc);
});
selectedDocumentIds.forEach((id) => {
if (seen.has(id)) return;
- const doc = documentLookup.get(id) || documentDetails.get(id)?.document || null;
+ const doc = documentLookup.get(id) || null;
pushDoc(doc);
});
return ordered;
- }, [selectionOrder, documentLookup, selectedDocumentIds, documentDetails]);
-
- useEffect(() => {
- if (!selectedDocumentIds.length) return;
- const selectedSet = new Set(selectedDocumentIds);
- const targetIds = [];
-
- for (let index = selectionOrder.length - 1; index >= 0; index -= 1) {
- const id = selectionOrder[index];
- if (selectedSet.has(id) && !targetIds.includes(id)) {
- targetIds.push(id);
- }
- if (targetIds.length >= MAX_PREVIEW_STACK_ITEMS) {
- break;
- }
- }
-
- if (!targetIds.length) {
- for (let index = selectedDocumentIds.length - 1; index >= 0; index -= 1) {
- const id = selectedDocumentIds[index];
- if (!targetIds.includes(id)) {
- targetIds.push(id);
- }
- if (targetIds.length >= MAX_PREVIEW_STACK_ITEMS) {
- break;
- }
- }
- }
-
- targetIds.forEach((id) => {
- ensureDocumentDetail(id, { force: false }).catch(() => {});
- });
- }, [selectionOrder, selectedDocumentIds, ensureDocumentDetail]);
+ }, [selectionOrder, documentLookup, selectedDocumentIds]);
const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => {
const chain = [];
@@ -4332,16 +4134,11 @@ const AppLayout = () => {
return previewEntries.get(previewDocumentId) || null;
}, [previewDocumentId, previewEntries]);
- const previewWorkspaceDetail = previewDocumentId
- ? documentDetails.get(previewDocumentId)
- : null;
-
const previewWorkspaceDocument = useMemo(() => {
if (!previewDocumentId) return null;
- if (previewWorkspaceDetail?.document) return previewWorkspaceDetail.document;
const pool = searchResults ?? documents;
return pool.find((doc) => doc.id === previewDocumentId) || null;
- }, [previewDocumentId, previewWorkspaceDetail, searchResults, documents]);
+ }, [previewDocumentId, searchResults, documents]);
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
@@ -4416,7 +4213,6 @@ const AppLayout = () => {
const detailPanelProps = {
selectedDocuments: orderedSelectedDocuments,
- detailMap: documentDetails,
tags,
tagLookupById,
tagLookupByLabel,
@@ -4489,7 +4285,6 @@ const AppLayout = () => {
handleDocumentTagAttach,
previewActive,
previewWorkspaceDocument,
- previewWorkspaceDetail,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
@@ -4515,7 +4310,6 @@ const AppLayout = () => {
handleDocumentTagAttach,
previewActive,
previewWorkspaceDocument,
- previewWorkspaceDetail,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
@@ -4643,7 +4437,6 @@ const DocumentsRoute = () => {
sidebarProps,
previewActive,
previewWorkspaceDocument,
- previewWorkspaceDetail,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
@@ -4658,7 +4451,6 @@ const DocumentsRoute = () => {
-
- {icon}
-
+ {!isRoot && (
+
+ {icon}
+
+ )}
{node.name}
diff --git a/frontend/src/skeuomorphic_ws.jsx b/frontend/src/skeuomorphic_ws.jsx
index 90e63d7..bceaf2e 100644
--- a/frontend/src/skeuomorphic_ws.jsx
+++ b/frontend/src/skeuomorphic_ws.jsx
@@ -7,6 +7,7 @@ import React, {
useState,
} from 'react';
import { resolveDocumentAssetUrl } from './asset_manager';
+import { generateRandomTagColor, getReadableTextColor } from './utils/colors';
import './skeuomorphic_ws.css';
const ITEM_WIDTH = 220;
@@ -299,15 +300,7 @@ const normalizeColor = (input) => {
return null;
};
-const getContrastingTextColor = (hex) => {
- if (!hex) return '#1b1f24';
- const value = parseInt(hex.slice(1), 16);
- const r = (value >> 16) & 0xff;
- const g = (value >> 8) & 0xff;
- const b = value & 0xff;
- const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
- return luminance > 0.6 ? '#1b1f24' : '#ffffff';
-};
+const getContrastingTextColor = (hex) => getReadableTextColor(hex, { light: '#1f2125' });
const SkeuomorphicWorkspace = ({
documents = [],
@@ -1555,11 +1548,8 @@ const SkeuomorphicWorkspace = ({
if (!label) {
return;
}
- const randomColor = `#${Math.floor(Math.random() * 0xffffff)
- .toString(16)
- .padStart(6, '0')}`;
try {
- await onCreateTag({ label, color: randomColor });
+ await onCreateTag({ label, color: generateRandomTagColor() });
setActiveShelfTagId(null);
} catch (error) {
console.error('Failed to create tag', error);
diff --git a/frontend/src/utils/colors.js b/frontend/src/utils/colors.js
index cc58593..ed7f586 100644
--- a/frontend/src/utils/colors.js
+++ b/frontend/src/utils/colors.js
@@ -1,5 +1,38 @@
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
+const clamp01 = (value) => Math.min(1, Math.max(0, value));
+
+const gammaEncode = (channel) =>
+ channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
+
+const oklchToHex = (l, c, h) => {
+ const hr = (h * Math.PI) / 180;
+ const a = Math.cos(hr) * c;
+ const b = Math.sin(hr) * c;
+
+ const l1 = l + 0.3963377774 * a + 0.2158037573 * b;
+ const m1 = l - 0.1055613458 * a - 0.0638541728 * b;
+ const s1 = l - 0.0894841775 * a - 1.291485548 * b;
+
+ const l3 = l1 ** 3;
+ const m3 = m1 ** 3;
+ const s3 = s1 ** 3;
+
+ const r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
+ const g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
+ const bLin = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3;
+
+ if ([r, g, bLin].some((channel) => channel < 0 || channel > 1)) {
+ return null;
+ }
+
+ const sr = Math.round(clamp01(gammaEncode(r)) * 255);
+ const sg = Math.round(clamp01(gammaEncode(g)) * 255);
+ const sb = Math.round(clamp01(gammaEncode(bLin)) * 255);
+
+ return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
+};
+
export const hexToRgb = (input) => {
if (!input) return null;
const match = HEX_COLOR_PATTERN.exec(input.trim());
@@ -13,28 +46,49 @@ export const hexToRgb = (input) => {
};
};
-const relativeLuminance = ({ r, g, b }) => {
- const transform = (channel) => {
+export const relativeLuminance = ({ r, g, b }) => {
+ const toLinear = (channel) => {
const normalized = channel / 255;
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
};
- const [red, green, blue] = [transform(r), transform(g), transform(b)];
+ const [red, green, blue] = [toLinear(r), toLinear(g), toLinear(b)];
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
};
+export const getReadableTextColor = (hex, { light = '#1f1f1f', dark = '#ffffff' } = {}) => {
+ const rgb = hexToRgb(hex);
+ if (!rgb) return dark;
+ const luminance = relativeLuminance(rgb);
+ return luminance > 0.6 ? light : dark;
+};
+
export const getTagColorStyle = (hex) => {
const rgb = hexToRgb(hex);
if (!rgb) return null;
- const luminance = relativeLuminance(rgb);
- const textColor = luminance > 0.6 ? '#1f1f1f' : '#ffffff';
return {
backgroundColor: rgb.hex,
borderColor: rgb.hex,
- color: textColor,
+ color: getReadableTextColor(rgb.hex),
};
};
+export const generateRandomTagColor = () => {
+ const lightness = 0.72 + (Math.random() - 0.5) * 0.08;
+ let chroma = 0.8;
+ const hue = Math.random() * 360;
+
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ const hex = oklchToHex(lightness, chroma, hue);
+ if (hex) {
+ return hex;
+ }
+ chroma *= 0.82;
+ }
+
+ return '#8c8982';
+};
+
export { HEX_COLOR_PATTERN };