This commit is contained in:
2025-10-14 23:20:42 +02:00
parent 4c36ea2e9a
commit 80161fe86e
5 changed files with 210 additions and 366 deletions
+7 -1
View File
@@ -275,7 +275,13 @@ const DocumentsTable = ({
focusedRowKey === `folder:${folder.id}` ? ' focused' : '' focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
}`} }`}
id={`folder-row-${folder.id}`} 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)} onDragOver={(event) => onFolderDragOver(event, folder.id)}
onDragLeave={onFolderDragLeave} onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop(event, folder.id)} onDrop={(event) => onFolderDrop(event, folder.id)}
+132 -340
View File
@@ -24,7 +24,7 @@ import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './as
import useApiError from './hooks/useApiError'; import useApiError from './hooks/useApiError';
import SkeuomorphicWorkspace from './skeuomorphic_ws'; import SkeuomorphicWorkspace from './skeuomorphic_ws';
import { DownloadIcon, EditIcon } from './ui/icons'; 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 Sidebar from './sidebar/Sidebar';
import DocumentsTable, { FilterBar } from './documents/DocumentsTable'; import DocumentsTable, { FilterBar } from './documents/DocumentsTable';
@@ -285,7 +285,6 @@ const PreviewStack = ({
const DetailPanel = ({ const DetailPanel = ({
selectedDocuments = [], selectedDocuments = [],
detailMap = new Map(),
tags = [], tags = [],
tagLookupById = new Map(), tagLookupById = new Map(),
tagLookupByLabel = new Map(), tagLookupByLabel = new Map(),
@@ -306,10 +305,8 @@ const DetailPanel = ({
ensureAssetUrl = null, ensureAssetUrl = null,
getDocumentAsset = () => null, getDocumentAsset = () => null,
}) => { }) => {
const lookup = detailMap && typeof detailMap.get === 'function' ? detailMap : new Map();
const selectedCount = selectedDocuments.length; const selectedCount = selectedDocuments.length;
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null; const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
const detail = singleDoc ? lookup.get(singleDoc.id) || null : null;
const [titleEditDocId, setTitleEditDocId] = useState(null); const [titleEditDocId, setTitleEditDocId] = useState(null);
const [titleDraft, setTitleDraft] = useState(''); const [titleDraft, setTitleDraft] = useState('');
@@ -465,26 +462,31 @@ const DetailPanel = ({
const stackTotalSizeBytes = useMemo(() => { const stackTotalSizeBytes = useMemo(() => {
if (!stackPreviews.length) return 0; if (!stackPreviews.length) return 0;
const byId = new Map(selectedDocuments.map((doc) => [doc.id, doc]));
return stackPreviews.reduce((sum, item) => { return stackPreviews.reduce((sum, item) => {
const detailEntry = lookup.get(item.id); const source = byId.get(item.id);
const bytes = detailEntry?.current_version?.size_bytes || 0; const bytes = source?.current_version?.size_bytes;
return sum + (typeof bytes === 'number' ? bytes : 0); return sum + (typeof bytes === 'number' ? bytes : 0);
}, 0); }, 0);
}, [stackPreviews, lookup]); }, [stackPreviews, selectedDocuments]);
const renderSingle = () => { const renderSingle = () => {
if (!singleDoc) { if (!singleDoc) {
return <p className="meta">Select a document to view metadata, tags and actions.</p>; return <p className="meta">Select a document to view metadata, tags and actions.</p>;
} }
if (!detail) {
return <p className="meta">Loading details</p>;
}
const displayName = singleDoc.title || singleDoc.original_name; const displayName = singleDoc.title || singleDoc.original_name;
const downloadHref = singleDoc.current_version?.download_path const downloadHref = singleDoc.current_version?.download_path
? resolveApiPath(singleDoc.current_version.download_path) ? resolveApiPath(singleDoc.current_version.download_path)
: null; : null;
const isEditingTitle = titleEditDocId === singleDoc.id; 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 ( return (
<> <>
@@ -555,18 +557,13 @@ const DetailPanel = ({
</div> </div>
<div> <div>
<strong>Size:</strong>{' '} <strong>Size:</strong>{' '}
{detail.current_version {sizeBytes ? `${(sizeBytes / 1024).toFixed(1)} KB` : '—'}
? `${(detail.current_version.size_bytes / 1024).toFixed(1)} KB`
: '—'}
</div> </div>
<div> <div>
<strong>Type:</strong> {singleDoc.content_type || 'Unknown'} <strong>Type:</strong> {singleDoc.content_type || 'Unknown'}
</div> </div>
<div> <div>
<strong>Issued:</strong>{' '} <strong>Issued:</strong> {issuedAt}
{detail.document.issued_at
? new Date(detail.document.issued_at).toLocaleString()
: '—'}
</div> </div>
<div> <div>
<strong>Original filename:</strong>{' '} <strong>Original filename:</strong>{' '}
@@ -607,8 +604,8 @@ const DetailPanel = ({
<div> <div>
<dt>Tags</dt> <dt>Tags</dt>
<div className="tag-list"> <div className="tag-list">
{detail.document.tags?.length ? ( {tagsForDoc.length ? (
detail.document.tags.map((tag) => { tagsForDoc.map((tag) => {
const colorSource = tag?.color || tagLookupById.get(tag.id)?.color; const colorSource = tag?.color || tagLookupById.get(tag.id)?.color;
const style = getTagColorStyle(colorSource); const style = getTagColorStyle(colorSource);
return ( return (
@@ -643,13 +640,12 @@ const DetailPanel = ({
</datalist> </datalist>
</form> </form>
</div> </div>
{detail.document.metadata && {metadata && (
Object.keys(detail.document.metadata).length > 0 && ( <div>
<div> <dt>Metadata</dt>
<dt>Metadata</dt> <pre>{JSON.stringify(metadata, null, 2)}</pre>
<pre>{JSON.stringify(detail.document.metadata, null, 2)}</pre> </div>
</div> )}
)}
</> </>
); );
}; };
@@ -685,48 +681,37 @@ const DetailPanel = ({
</div> </div>
{commonTags.length > 0 && ( {commonTags.length > 0 && (
<div className="bulk-tags"> <div className="bulk-tags">
{commonTags.map((label) => { <strong>Bulk tag operations</strong>
const key = typeof label === 'string' ? label.toLowerCase() : ''; <form
const tagInfo = key ? tagLookupByLabel.get(key) : null; className="inline"
const style = getTagColorStyle(tagInfo?.color); onSubmit={(event) => {
return ( event.preventDefault();
<span key={label} className="tag-pill" style={style || undefined}> const input = event.currentTarget.elements.tag;
{label} const value = input.value.trim();
</span> if (!value) return;
); onBulkTagAdd?.({ label: value, input });
})} }}
>
<input name="tag" placeholder="Add tag to selection" list="tag-catalog" />
<button type="submit">Add tag</button>
</form>
<form
className="inline"
onSubmit={(event) => {
event.preventDefault();
const input = event.currentTarget.elements.tag;
const value = input.value.trim();
if (!value) return;
onBulkTagRemove?.({ label: value, input });
}}
>
<input name="tag" placeholder="Remove tag from selection" list="tag-catalog" />
<button type="submit" className="secondary">
Remove tag
</button>
</form>
</div> </div>
)} )}
<div className="bulk-detail-actions">
<form
className="inline"
onSubmit={(event) => {
event.preventDefault();
const input = event.currentTarget.elements.tag;
const value = input.value.trim();
if (!value) return;
onBulkTagAdd?.({ label: value, input });
}}
>
<input name="tag" placeholder="Add tag to selection" list="tag-catalog" />
<button type="submit">Add tag</button>
</form>
<form
className="inline"
onSubmit={(event) => {
event.preventDefault();
const input = event.currentTarget.elements.tag;
const value = input.value.trim();
if (!value) return;
onBulkTagRemove?.({ label: value, input });
}}
>
<input name="tag" placeholder="Remove tag from selection" list="tag-catalog" />
<button type="submit" className="secondary">
Remove tag
</button>
</form>
</div>
<datalist id="tag-catalog"> <datalist id="tag-catalog">
{tags.map((tag) => ( {tags.map((tag) => (
<option key={tag.id} value={tag.label} /> <option key={tag.id} value={tag.label} />
@@ -774,7 +759,6 @@ const DetailPanel = ({
const PreviewWorkspace = ({ const PreviewWorkspace = ({
document, document,
detail,
previewEntry, previewEntry,
onClose, onClose,
onRegenerateThumbnails, onRegenerateThumbnails,
@@ -783,15 +767,14 @@ const PreviewWorkspace = ({
return null; return null;
} }
const title = const title = document.title || document.original_name || 'Document';
detail?.document?.title ||
document.title ||
detail?.document?.original_name ||
document.original_name;
const mime = previewEntry?.contentType || document.content_type || 'application/pdf'; const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
const downloadHref = document.current_version?.download_path const downloadHref = document.current_version?.download_path
? resolveApiPath(document.current_version.download_path) ? resolveApiPath(document.current_version.download_path)
: null; : null;
const sizeBytes = Number(document.current_version?.size_bytes) || 0;
const metadata =
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
return ( return (
<section className="preview-workspace"> <section className="preview-workspace">
@@ -800,17 +783,15 @@ const PreviewWorkspace = ({
<button <button
type="button" type="button"
className="secondary" className="secondary"
onClick={() => onClose(detail?.document?.folder_id ?? document.folder_id ?? 'root')} onClick={() => onClose(document.folder_id ?? 'root')}
> >
Back Back
</button> </button>
<div> <div>
<h2>{title}</h2> <h2>{title}</h2>
<span className="meta"> <span className="meta">
{document.content_type || 'Document'} {document.content_type || mime}
{detail?.current_version?.size_bytes {sizeBytes ? ` · ${(sizeBytes / 1024 / 1024).toFixed(2)} MB` : ''}
? ` · ${(detail.current_version.size_bytes / 1024 / 1024).toFixed(2)} MB`
: ''}
</span> </span>
</div> </div>
</div> </div>
@@ -850,10 +831,10 @@ const PreviewWorkspace = ({
/> />
)} )}
</div> </div>
{detail?.document?.metadata && Object.keys(detail.document.metadata).length > 0 && ( {metadata && (
<section className="preview-workspace__metadata"> <section className="preview-workspace__metadata">
<h3>Metadata</h3> <h3>Metadata</h3>
<pre>{JSON.stringify(detail.document.metadata, null, 2)}</pre> <pre>{JSON.stringify(metadata, null, 2)}</pre>
</section> </section>
)} )}
</section> </section>
@@ -1133,8 +1114,6 @@ const AppLayout = () => {
const [focusedRowKey, setFocusedRowKey] = useState(() => const [focusedRowKey, setFocusedRowKey] = useState(() =>
routeDocumentId ? `document:${routeDocumentId}` : null, routeDocumentId ? `document:${routeDocumentId}` : null,
); );
const [documentDetails, setDocumentDetails] = useState(() => new Map());
const documentDetailsRef = useRef(documentDetails);
const tokenRef = useRef(token); const tokenRef = useRef(token);
const refreshPromiseRef = useRef(null); const refreshPromiseRef = useRef(null);
const breadcrumbFetchRef = useRef(new Set()); const breadcrumbFetchRef = useRef(new Set());
@@ -1240,46 +1219,6 @@ const AppLayout = () => {
return { ...doc, current_version: updatedCurrentVersion }; 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 bootstrapInitializedRef = useRef(false);
const selectionInitializedRef = useRef(false); const selectionInitializedRef = useRef(false);
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
@@ -1302,11 +1241,6 @@ const AppLayout = () => {
selectionAnchorRef.current = null; selectionAnchorRef.current = null;
setDraggedDocumentIds([]); setDraggedDocumentIds([]);
setDraggedFolderId(null); setDraggedFolderId(null);
setDocumentDetails(() => {
const next = new Map();
documentDetailsRef.current = next;
return next;
});
setSearchResults(null); setSearchResults(null);
setTags([]); setTags([]);
setSearchQuery(''); setSearchQuery('');
@@ -1382,10 +1316,6 @@ const AppLayout = () => {
} }
}, [appStatus, resetWorkspaceState]); }, [appStatus, resetWorkspaceState]);
useEffect(() => {
documentDetailsRef.current = documentDetails;
}, [documentDetails]);
useEffect(() => { useEffect(() => {
tokenRef.current = token; tokenRef.current = token;
}, [token]); }, [token]);
@@ -2032,38 +1962,6 @@ const AppLayout = () => {
setDraggedFolderId(null); setDraggedFolderId(null);
}, [setDraggedFolderId]); }, [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 () => { const refreshTags = useCallback(async () => {
try { try {
const { data } = await api.get('/tags'); const { data } = await api.get('/tags');
@@ -2111,7 +2009,7 @@ const AppLayout = () => {
if (!trimmed) { if (!trimmed) {
throw new Error('Tag label is required.'); throw new Error('Tag label is required.');
} }
const payload = { label: trimmed, color: color || null }; const payload = { label: trimmed, color: color || generateRandomTagColor() };
try { try {
await api.post('/tags', payload); await api.post('/tags', payload);
await refreshTags(); await refreshTags();
@@ -2139,18 +2037,15 @@ const AppLayout = () => {
console.warn('Failed to refresh root folder tree', error); console.warn('Failed to refresh root folder tree', error);
} }
} }
const nextSelectedId = applySelectedFolder(targetId, contents); applySelectedFolder(targetId, contents);
setSearchResults(null); setSearchResults(null);
if (nextSelectedId) {
await ensureDocumentDetail(nextSelectedId, { force: true });
}
} catch (error) { } catch (error) {
notifyApiError(error, 'Failed to load folder contents.'); notifyApiError(error, 'Failed to load folder contents.');
} finally { } finally {
if (showLoading) setLoading(false); if (showLoading) setLoading(false);
} }
}, },
[ensureFolderData, applySelectedFolder, ensureDocumentDetail, notifyApiError], [ensureFolderData, applySelectedFolder, notifyApiError],
); );
useEffect(() => { useEffect(() => {
@@ -2236,16 +2131,13 @@ const AppLayout = () => {
setLoading(true); setLoading(true);
try { try {
const contents = await ensureFolderData(selectedFolder, { force: true }); const contents = await ensureFolderData(selectedFolder, { force: true });
const nextSelectedId = applySelectedFolder(selectedFolder, contents); applySelectedFolder(selectedFolder, contents);
if (nextSelectedId) {
await ensureDocumentDetail(nextSelectedId, { force: true });
}
} catch (error) { } catch (error) {
notifyApiError(error, 'Failed to refresh folder.'); notifyApiError(error, 'Failed to refresh folder.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [selectedFolder, ensureFolderData, applySelectedFolder, ensureDocumentDetail, notifyApiError]); }, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]);
useEffect(() => { useEffect(() => {
if (appStatus !== 'authenticated') { if (appStatus !== 'authenticated') {
@@ -2401,9 +2293,7 @@ const AppLayout = () => {
action, action,
}); });
await Promise.all( await refreshCurrentFolder();
selectedDocumentIds.map((id) => ensureDocumentDetail(id, { force: true }).catch(() => null)),
);
return { return {
ok: true, ok: true,
@@ -2425,7 +2315,7 @@ const AppLayout = () => {
tags, tags,
api, api,
refreshTags, refreshTags,
ensureDocumentDetail, refreshCurrentFolder,
notifyApiError, notifyApiError,
setLoading, setLoading,
], ],
@@ -2673,21 +2563,6 @@ const AppLayout = () => {
: prev, : 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; return entry;
} catch (error) { } catch (error) {
notifyApiError(error, 'Unable to refresh document asset.'); notifyApiError(error, 'Unable to refresh document asset.');
@@ -2698,7 +2573,6 @@ const AppLayout = () => {
assetManager, assetManager,
setDocuments, setDocuments,
setSearchResults, setSearchResults,
setDocumentDetails,
notifyApiError, notifyApiError,
], ],
); );
@@ -2968,12 +2842,6 @@ const AppLayout = () => {
'success', 'success',
); );
setDocumentDetails((prev) => {
const next = new Map(prev);
unique.forEach((id) => next.delete(id));
return next;
});
await refreshCurrentFolder(); await refreshCurrentFolder();
if (targetFolderId && targetFolderId !== selectedFolder) { if (targetFolderId && targetFolderId !== selectedFolder) {
await ensureFolderData(targetFolderId, { force: true }); await ensureFolderData(targetFolderId, { force: true });
@@ -3020,7 +2888,7 @@ const AppLayout = () => {
params: { force: true }, params: { force: true },
}); });
setStatusMessage('Document re-analysis queued.', 'info'); setStatusMessage('Document re-analysis queued.', 'info');
await ensureDocumentDetail(documentId, { force: true }); await refreshCurrentFolder();
} catch (error) { } catch (error) {
const message = const message =
error.response?.data?.error || 'Failed to request thumbnail generation.'; error.response?.data?.error || 'Failed to request thumbnail generation.';
@@ -3029,7 +2897,7 @@ const AppLayout = () => {
setLoading(false); setLoading(false);
} }
}, },
[token, ensureDocumentDetail, notifyApiError, setStatusMessage], [token, refreshCurrentFolder, notifyApiError, setStatusMessage],
); );
const openDocumentPreview = useCallback( const openDocumentPreview = useCallback(
@@ -3038,46 +2906,34 @@ const AppLayout = () => {
setPreviewDocumentId(documentId); setPreviewDocumentId(documentId);
setPreviewDocumentLoading(true); setPreviewDocumentLoading(true);
try { 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( const currentVersion = doc.current_version || null;
detailData?.document?.current_version || null, const previewAsset = getAssetFromVersion(currentVersion, 'preview');
'preview', const thumbnailAsset = getAssetFromVersion(currentVersion, 'thumbnail');
);
if (previewAsset) { const refreshAssetIfNeeded = async (asset) => {
const previewExpiresAt = if (!asset?.id) {
typeof previewAsset.expiresAt === 'number' ? previewAsset.expiresAt : null; return;
const previewNeedsRefresh = }
!previewAsset.url || (previewExpiresAt && previewExpiresAt <= Date.now()); const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
if (previewNeedsRefresh) { const shouldForce = Boolean(asset.url && expiresAt && expiresAt <= Date.now());
if (!asset.url || shouldForce) {
try { try {
await ensureAssetUrl(documentId, previewAsset, { await ensureAssetUrl(documentId, asset, { force: shouldForce || !asset.url });
force: Boolean(previewExpiresAt && previewExpiresAt <= Date.now()),
});
} catch ( } catch (
// eslint-disable-next-line no-empty // eslint-disable-next-line no-empty
error error
) {} ) {}
} }
} };
const thumbnailAsset = getAssetFromVersion( await refreshAssetIfNeeded(previewAsset);
detailData?.document?.current_version || null, refreshAssetIfNeeded(thumbnailAsset);
'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 ensurePreviewUrl(documentId, { force: false }); await ensurePreviewUrl(documentId, { force: false });
setActivePreviewId(documentId); setActivePreviewId(documentId);
@@ -3091,7 +2947,14 @@ const AppLayout = () => {
setPreviewDocumentLoading(false); setPreviewDocumentLoading(false);
} }
}, },
[ensureDocumentDetail, ensurePreviewUrl, ensureAssetUrl, navigate, notifyApiError], [
documents,
searchResults,
ensurePreviewUrl,
ensureAssetUrl,
navigate,
notifyApiError,
],
); );
const handleDocumentListFocus = useCallback(() => { const handleDocumentListFocus = useCallback(() => {
@@ -3301,8 +3164,16 @@ const AppLayout = () => {
const hydratePreview = async () => { const hydratePreview = async () => {
try { try {
const detail = await ensureDocumentDetail(routeDocumentId, { force: true }); const pool = searchResults ?? documents;
const doc = detail?.document || documentDetailsRef.current.get(routeDocumentId)?.document; 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'; const targetFolder = doc?.folder_id || routeFolderId || 'root';
if (targetFolder && targetFolder !== selectedFolder) { if (targetFolder && targetFolder !== selectedFolder) {
await loadFolder(targetFolder, { showLoading: false }); await loadFolder(targetFolder, { showLoading: false });
@@ -3325,12 +3196,15 @@ const AppLayout = () => {
}, [ }, [
routeDocumentId, routeDocumentId,
routeFolderId, routeFolderId,
ensureDocumentDetail, api,
assetManager,
selectedFolder, selectedFolder,
loadFolder, loadFolder,
openDocumentPreview, openDocumentPreview,
previewDocumentId, previewDocumentId,
closeDocumentPreview, closeDocumentPreview,
documents,
searchResults,
notifyApiError, notifyApiError,
]); ]);
@@ -3346,14 +3220,7 @@ const AppLayout = () => {
try { try {
const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed }); const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
const hydratedDetail = assetManager.hydrateDetail(data); const hydratedDetail = assetManager.hydrateDetail(data);
const hydratedDocument = hydratedDetail?.document || data.document; const hydratedDocument = hydratedDetail?.document || data.document || data;
setDocumentDetails((prev) => {
const next = new Map(prev);
next.set(documentId, hydratedDetail);
documentDetailsRef.current = next;
return next;
});
setDocuments((prev) => setDocuments((prev) =>
prev.map((doc) => { prev.map((doc) => {
@@ -3390,7 +3257,11 @@ const AppLayout = () => {
next.set(key, { next.set(key, {
...contents, ...contents,
documents: contents.documents.map((doc) => documents: contents.documents.map((doc) =>
doc.id === documentId ? { ...doc, title: trimmed } : doc, doc.id === documentId
? hydratedDocument
? { ...doc, ...hydratedDocument }
: { ...doc, title: trimmed }
: doc,
), ),
}); });
} else { } else {
@@ -3421,16 +3292,12 @@ const AppLayout = () => {
try { try {
await api.delete(`/documents/${documentId}/tags/${tagId}`); await api.delete(`/documents/${documentId}/tags/${tagId}`);
setStatusMessage('Tag removed.', 'success'); setStatusMessage('Tag removed.', 'success');
await Promise.all([ await Promise.all([refreshCurrentFolder(), refreshTags()]);
refreshCurrentFolder(),
refreshTags(),
ensureDocumentDetail(documentId, { force: true }),
]);
} catch (error) { } catch (error) {
notifyApiError(error, 'Failed to remove tag.'); notifyApiError(error, 'Failed to remove tag.');
} }
}, },
[ensureDocumentDetail, refreshCurrentFolder, refreshTags, notifyApiError, setStatusMessage], [refreshCurrentFolder, refreshTags, notifyApiError, setStatusMessage],
); );
const handleTagAdd = useCallback( const handleTagAdd = useCallback(
@@ -3438,17 +3305,17 @@ const AppLayout = () => {
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null; let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
try { try {
if (!tag) { if (!tag) {
const { data } = await api.post('/tags', { label, color: null }); const { data } = await api.post('/tags', {
label,
color: generateRandomTagColor(),
});
tag = data; tag = data;
await refreshTags(); await refreshTags();
} }
await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] }); await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] });
setStatusMessage('Tag assigned.', 'success'); setStatusMessage('Tag assigned.', 'success');
input.value = ''; input.value = '';
await Promise.all([ await refreshCurrentFolder();
refreshCurrentFolder(),
ensureDocumentDetail(document.id, { force: true }),
]);
} catch (error) { } catch (error) {
notifyApiError(error, 'Failed to assign tag.'); notifyApiError(error, 'Failed to assign tag.');
} }
@@ -3457,7 +3324,6 @@ const AppLayout = () => {
tags, tags,
refreshTags, refreshTags,
refreshCurrentFolder, refreshCurrentFolder,
ensureDocumentDetail,
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
], ],
@@ -3472,10 +3338,7 @@ const AppLayout = () => {
try { try {
await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] }); await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] });
setStatusMessage('Tag assigned.', 'success'); setStatusMessage('Tag assigned.', 'success');
await Promise.all([ await refreshCurrentFolder();
refreshCurrentFolder(),
ensureDocumentDetail(documentId, { force: true }),
]);
return true; return true;
} catch (error) { } catch (error) {
const message = error.response?.data?.error || 'Failed to assign tag.'; const message = error.response?.data?.error || 'Failed to assign tag.';
@@ -3483,7 +3346,7 @@ const AppLayout = () => {
return false; return false;
} }
}, },
[api, refreshCurrentFolder, ensureDocumentDetail, notifyApiError, setStatusMessage], [api, refreshCurrentFolder, notifyApiError, setStatusMessage],
); );
const handleFolderDelete = useCallback( const handleFolderDelete = useCallback(
@@ -3532,10 +3395,7 @@ const AppLayout = () => {
const parentId = node?.parentId || 'root'; const parentId = node?.parentId || 'root';
setSelectedFolder(parentId); setSelectedFolder(parentId);
const parentContents = await ensureFolderData(parentId, { force: true }); const parentContents = await ensureFolderData(parentId, { force: true });
const nextSelectedId = applySelectedFolder(parentId, parentContents); applySelectedFolder(parentId, parentContents);
if (nextSelectedId) {
await ensureDocumentDetail(nextSelectedId, { force: true });
}
} else if (selectedFolder !== 'root') { } else if (selectedFolder !== 'root') {
await ensureFolderData(selectedFolder, { force: true }); await ensureFolderData(selectedFolder, { force: true });
} }
@@ -3553,7 +3413,6 @@ const AppLayout = () => {
selectedFolder, selectedFolder,
folderNodes, folderNodes,
applySelectedFolder, applySelectedFolder,
ensureDocumentDetail,
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
], ],
@@ -3750,10 +3609,7 @@ const AppLayout = () => {
selectionAnchorRef.current = targetId; selectionAnchorRef.current = targetId;
if (!cancelled && targetId) { // rely on hydrated search results; assets refresh on demand
const cached = documentDetailsRef.current.get(targetId);
await ensureDocumentDetail(targetId, { force: !cached });
}
} catch (error) { } catch (error) {
if (cancelled) return; if (cancelled) return;
notifyApiError(error, 'Search failed. Please try again.'); notifyApiError(error, 'Search failed. Please try again.');
@@ -3778,7 +3634,6 @@ const AppLayout = () => {
searchQuery, searchQuery,
activeTagFilters, activeTagFilters,
selectedFolder, selectedFolder,
ensureDocumentDetail,
notifyApiError, notifyApiError,
assetManager, assetManager,
]); ]);
@@ -4048,11 +3903,6 @@ const AppLayout = () => {
: 'root'; : 'root';
}, [folderOptions, selectedFolder]); }, [folderOptions, selectedFolder]);
useEffect(() => {
if (!focusedDocumentId) return;
ensureDocumentDetail(focusedDocumentId, { force: false }).catch(() => {});
}, [focusedDocumentId, ensureDocumentDetail]);
const selectedDocument = useMemo(() => { const selectedDocument = useMemo(() => {
if (!focusedDocumentId) { if (!focusedDocumentId) {
return null; return null;
@@ -4061,10 +3911,6 @@ const AppLayout = () => {
return list.find((doc) => doc.id === focusedDocumentId) || null; return list.find((doc) => doc.id === focusedDocumentId) || null;
}, [searchResults, documents, focusedDocumentId]); }, [searchResults, documents, focusedDocumentId]);
const selectedDetail = selectedDocument
? documentDetails.get(selectedDocument.id)
: null;
const documentLookup = useMemo(() => { const documentLookup = useMemo(() => {
const map = new Map(); const map = new Map();
documents.forEach((doc) => { documents.forEach((doc) => {
@@ -4090,7 +3936,7 @@ const AppLayout = () => {
return; 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 label = doc?.title || doc?.original_name || 'this document';
const confirmed = window.confirm(`Delete "${label}"? This action cannot be undone.`); 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)); 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) => { setFolderContents((prev) => {
let changed = false; let changed = false;
const next = new Map(); const next = new Map();
@@ -4166,11 +4002,9 @@ const AppLayout = () => {
api, api,
token, token,
documentLookup, documentLookup,
documentDetails,
setStatusMessage, setStatusMessage,
setDocuments, setDocuments,
setSearchResults, setSearchResults,
setDocumentDetails,
setFolderContents, setFolderContents,
setPreviewEntries, setPreviewEntries,
previewInflightRef, previewInflightRef,
@@ -4193,50 +4027,18 @@ const AppLayout = () => {
}; };
selectionOrder.forEach((id) => { selectionOrder.forEach((id) => {
const doc = documentLookup.get(id) || documentDetails.get(id)?.document || null; const doc = documentLookup.get(id) || null;
pushDoc(doc); pushDoc(doc);
}); });
selectedDocumentIds.forEach((id) => { selectedDocumentIds.forEach((id) => {
if (seen.has(id)) return; if (seen.has(id)) return;
const doc = documentLookup.get(id) || documentDetails.get(id)?.document || null; const doc = documentLookup.get(id) || null;
pushDoc(doc); pushDoc(doc);
}); });
return ordered; return ordered;
}, [selectionOrder, documentLookup, selectedDocumentIds, documentDetails]); }, [selectionOrder, documentLookup, selectedDocumentIds]);
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]);
const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => { const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => {
const chain = []; const chain = [];
@@ -4332,16 +4134,11 @@ const AppLayout = () => {
return previewEntries.get(previewDocumentId) || null; return previewEntries.get(previewDocumentId) || null;
}, [previewDocumentId, previewEntries]); }, [previewDocumentId, previewEntries]);
const previewWorkspaceDetail = previewDocumentId
? documentDetails.get(previewDocumentId)
: null;
const previewWorkspaceDocument = useMemo(() => { const previewWorkspaceDocument = useMemo(() => {
if (!previewDocumentId) return null; if (!previewDocumentId) return null;
if (previewWorkspaceDetail?.document) return previewWorkspaceDetail.document;
const pool = searchResults ?? documents; const pool = searchResults ?? documents;
return pool.find((doc) => doc.id === previewDocumentId) || null; return pool.find((doc) => doc.id === previewDocumentId) || null;
}, [previewDocumentId, previewWorkspaceDetail, searchResults, documents]); }, [previewDocumentId, searchResults, documents]);
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument); const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
@@ -4416,7 +4213,6 @@ const AppLayout = () => {
const detailPanelProps = { const detailPanelProps = {
selectedDocuments: orderedSelectedDocuments, selectedDocuments: orderedSelectedDocuments,
detailMap: documentDetails,
tags, tags,
tagLookupById, tagLookupById,
tagLookupByLabel, tagLookupByLabel,
@@ -4489,7 +4285,6 @@ const AppLayout = () => {
handleDocumentTagAttach, handleDocumentTagAttach,
previewActive, previewActive,
previewWorkspaceDocument, previewWorkspaceDocument,
previewWorkspaceDetail,
previewWorkspaceEntry, previewWorkspaceEntry,
closeDocumentPreview, closeDocumentPreview,
handleThumbnailRegeneration, handleThumbnailRegeneration,
@@ -4515,7 +4310,6 @@ const AppLayout = () => {
handleDocumentTagAttach, handleDocumentTagAttach,
previewActive, previewActive,
previewWorkspaceDocument, previewWorkspaceDocument,
previewWorkspaceDetail,
previewWorkspaceEntry, previewWorkspaceEntry,
closeDocumentPreview, closeDocumentPreview,
handleThumbnailRegeneration, handleThumbnailRegeneration,
@@ -4643,7 +4437,6 @@ const DocumentsRoute = () => {
sidebarProps, sidebarProps,
previewActive, previewActive,
previewWorkspaceDocument, previewWorkspaceDocument,
previewWorkspaceDetail,
previewWorkspaceEntry, previewWorkspaceEntry,
closeDocumentPreview, closeDocumentPreview,
handleThumbnailRegeneration, handleThumbnailRegeneration,
@@ -4658,7 +4451,6 @@ const DocumentsRoute = () => {
<main className="preview-main"> <main className="preview-main">
<PreviewWorkspace <PreviewWorkspace
document={previewWorkspaceDocument} document={previewWorkspaceDocument}
detail={previewWorkspaceDetail}
previewEntry={previewWorkspaceEntry} previewEntry={previewWorkspaceEntry}
onClose={closeDocumentPreview} onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration} onRegenerateThumbnails={handleThumbnailRegeneration}
+8 -6
View File
@@ -56,12 +56,14 @@ const FolderNode = ({
} }
}} }}
> >
<span {!isRoot && (
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`} <span
onClick={handleToggleClick} className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
> onClick={handleToggleClick}
{icon} >
</span> {icon}
</span>
)}
<span className="name"> <span className="name">
<FolderIcon className="folder-icon" /> <FolderIcon className="folder-icon" />
{node.name} {node.name}
+3 -13
View File
@@ -7,6 +7,7 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { resolveDocumentAssetUrl } from './asset_manager'; import { resolveDocumentAssetUrl } from './asset_manager';
import { generateRandomTagColor, getReadableTextColor } from './utils/colors';
import './skeuomorphic_ws.css'; import './skeuomorphic_ws.css';
const ITEM_WIDTH = 220; const ITEM_WIDTH = 220;
@@ -299,15 +300,7 @@ const normalizeColor = (input) => {
return null; return null;
}; };
const getContrastingTextColor = (hex) => { const getContrastingTextColor = (hex) => getReadableTextColor(hex, { light: '#1f2125' });
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 SkeuomorphicWorkspace = ({ const SkeuomorphicWorkspace = ({
documents = [], documents = [],
@@ -1555,11 +1548,8 @@ const SkeuomorphicWorkspace = ({
if (!label) { if (!label) {
return; return;
} }
const randomColor = `#${Math.floor(Math.random() * 0xffffff)
.toString(16)
.padStart(6, '0')}`;
try { try {
await onCreateTag({ label, color: randomColor }); await onCreateTag({ label, color: generateRandomTagColor() });
setActiveShelfTagId(null); setActiveShelfTagId(null);
} catch (error) { } catch (error) {
console.error('Failed to create tag', error); console.error('Failed to create tag', error);
+60 -6
View File
@@ -1,5 +1,38 @@
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/; 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) => { export const hexToRgb = (input) => {
if (!input) return null; if (!input) return null;
const match = HEX_COLOR_PATTERN.exec(input.trim()); const match = HEX_COLOR_PATTERN.exec(input.trim());
@@ -13,28 +46,49 @@ export const hexToRgb = (input) => {
}; };
}; };
const relativeLuminance = ({ r, g, b }) => { export const relativeLuminance = ({ r, g, b }) => {
const transform = (channel) => { const toLinear = (channel) => {
const normalized = channel / 255; const normalized = channel / 255;
return normalized <= 0.03928 return normalized <= 0.03928
? normalized / 12.92 ? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4; : ((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; 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) => { export const getTagColorStyle = (hex) => {
const rgb = hexToRgb(hex); const rgb = hexToRgb(hex);
if (!rgb) return null; if (!rgb) return null;
const luminance = relativeLuminance(rgb);
const textColor = luminance > 0.6 ? '#1f1f1f' : '#ffffff';
return { return {
backgroundColor: rgb.hex, backgroundColor: rgb.hex,
borderColor: 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 }; export { HEX_COLOR_PATTERN };