From 80161fe86e5d219833ff688fb446d3e65519fdfd Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 14 Oct 2025 23:20:08 +0200 Subject: [PATCH] frontend --- frontend/src/documents/DocumentsTable.jsx | 8 +- frontend/src/index.jsx | 472 ++++++---------------- frontend/src/sidebar/Sidebar.jsx | 14 +- frontend/src/skeuomorphic_ws.jsx | 16 +- frontend/src/utils/colors.js | 66 ++- 5 files changed, 210 insertions(+), 366 deletions(-) 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 +
{ + event.preventDefault(); + const input = event.currentTarget.elements.tag; + const value = input.value.trim(); + if (!value) return; + onBulkTagAdd?.({ label: value, input }); + }} + > + + +
+
{ + event.preventDefault(); + const input = event.currentTarget.elements.tag; + const value = input.value.trim(); + if (!value) return; + onBulkTagRemove?.({ label: value, input }); + }} + > + + +
)} -
-
{ - event.preventDefault(); - const input = event.currentTarget.elements.tag; - const value = input.value.trim(); - if (!value) return; - onBulkTagAdd?.({ label: value, input }); - }} - > - - -
-
{ - event.preventDefault(); - const input = event.currentTarget.elements.tag; - const value = input.value.trim(); - if (!value) return; - onBulkTagRemove?.({ label: value, input }); - }} - > - - -
-
{tags.map((tag) => (
@@ -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 };