From 9aa43d27534e3c1f1149a85768f5d1aeff011a98 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 14 Oct 2025 02:43:38 +0200 Subject: [PATCH] 7 --- backend/src/models.rs | 2 +- frontend/src/asset_manager.js | 75 ++++------- frontend/src/index.jsx | 218 +++++++++++++++---------------- frontend/src/skeuomorphic_ws.css | 41 +----- frontend/src/skeuomorphic_ws.jsx | 145 +++++++------------- 5 files changed, 183 insertions(+), 298 deletions(-) diff --git a/backend/src/models.rs b/backend/src/models.rs index d0d4187..e199b4b 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -53,13 +53,13 @@ pub struct Document { pub original_name: String, pub content_type: Option, pub folder_id: Option, - pub current_version_id: Uuid, pub uploaded_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub deleted_at: Option, pub metadata: serde_json::Value, pub issued_at: Option, pub title: String, + pub current_version_id: Uuid, } #[derive(Debug, Insertable)] diff --git a/frontend/src/asset_manager.js b/frontend/src/asset_manager.js index 0347deb..eeb7f33 100644 --- a/frontend/src/asset_manager.js +++ b/frontend/src/asset_manager.js @@ -17,14 +17,34 @@ export const getAssetFromVersion = (currentVersion, assetType) => { return getAssetFromGroup(currentVersion.assets, assetType); }; +export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, ensureOptions } = {}) => { + if (!doc || !type) { + return null; + } + const asset = typeof getAsset === 'function' ? getAsset(doc, type) : null; + if (!asset) { + return null; + } + const now = Date.now(); + const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; + const hasFreshUrl = asset.url && (!expiresAt || expiresAt > now); + if (hasFreshUrl) { + return asset.url; + } + if (doc.id && asset.id && typeof ensureAssetUrl === 'function') { + const force = Boolean(asset.url && expiresAt && expiresAt <= now); + const options = ensureOptions ? { ...ensureOptions, force } : { force }; + ensureAssetUrl(doc.id, asset, options).catch(() => {}); + } + return null; +}; + class AssetManager { constructor({ api, assetPresignTtlMs }) { this.api = api; this.assetPresignTtlMs = assetPresignTtlMs; this.assetCache = new Map(); this.assetInflight = new Map(); - this.previewCache = new Map(); - this.previewInflight = new Map(); } setApi(api) { @@ -197,60 +217,9 @@ class AssetManager { return request; } - ensurePreview(documentId, { force = false } = {}) { - if (!documentId) { - return Promise.resolve(null); - } - const cached = this.previewCache.get(documentId); - const now = Date.now(); - if (!force && cached && (!cached.expiresAt || cached.expiresAt > now)) { - return Promise.resolve(cached); - } - - if (!force && this.previewInflight.has(documentId)) { - return this.previewInflight.get(documentId); - } - - if (!this.api) { - return Promise.reject(new Error('AssetManager API client is not configured.')); - } - - const request = this.api - .get(`/documents/${documentId}/download`) - .then(({ data }) => { - const ttl = data.expires_in ? Math.max(data.expires_in - 60, 30) * 1000 : 5 * 60 * 1000; - const entry = { - url: data.url, - contentType: data.content_type || null, - filename: data.filename, - expiresAt: Date.now() + ttl, - }; - this.previewCache.set(documentId, entry); - return entry; - }) - .finally(() => { - this.previewInflight.delete(documentId); - }); - - this.previewInflight.set(documentId, request); - return request; - } - - getPreview(documentId) { - return this.previewCache.get(documentId) || null; - } - - deleteDocument(documentId) { - if (!documentId) return; - this.previewCache.delete(documentId); - this.previewInflight.delete(documentId); - } - reset() { this.assetCache.clear(); this.assetInflight.clear(); - this.previewCache.clear(); - this.previewInflight.clear(); } } diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 4f6e76f..0dadcf1 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -21,7 +21,7 @@ import { matchPath, } from 'react-router-dom'; import './styles.css'; -import AssetManager, { getAssetFromVersion } from './asset_manager'; +import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager'; import useApiError from './hooks/useApiError'; import SkeuomorphicWorkspace from './skeuomorphic_ws'; @@ -502,25 +502,20 @@ const FilterBar = ({ ); -const DocumentThumbnailImage = ({ documentId, asset, ensureAssetUrl, alt }) => { - useEffect(() => { - if (!documentId || !asset?.id || !ensureAssetUrl) { - return; - } - const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; - const now = Date.now(); - const needsRefresh = !asset?.url || (expiresAt && expiresAt <= now); - if (!needsRefresh) { - return; - } - const force = Boolean(asset.url && expiresAt && expiresAt <= now); - ensureAssetUrl(documentId, asset, { force }).catch(() => {}); - }, [documentId, asset?.id, asset?.url, asset?.expiresAt, ensureAssetUrl]); +const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => { + const url = useMemo( + () => + resolveDocumentAssetUrl(document, 'thumbnail', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }), + [document, ensureAssetUrl, getDocumentAsset], + ); - if (asset?.url) { + if (url) { return ( {alt @@ -567,10 +562,6 @@ const DocumentsTable = ({ const showingSearchResults = searchResults !== null; const rows = showingSearchResults ? searchResults : documents; - const resolveThumbnailAsset = useCallback((doc) => { - if (!doc) return null; - return getDocumentAsset(doc, 'thumbnail'); - }, [getDocumentAsset]); const selectedSet = useMemo( () => new Set(selectedDocumentIds), [selectedDocumentIds], @@ -775,8 +766,6 @@ const DocumentsTable = ({ rowClasses.push('dragging'); } - const thumbnailAsset = resolveThumbnailAsset(doc); - return ( - {thumbnailAsset ? ( - - ) : ( -
DOC
- )} +
@@ -1076,28 +1061,29 @@ const DetailPanel = ({ [onPromoteSelection], ); - const resolvePreviewAsset = useCallback((doc) => { - if (!doc) return null; - return getDocumentAsset(doc, 'preview'); - }, [getDocumentAsset]); - - const makePreviewItem = useCallback((doc) => { - if (!doc) return null; - const previewAsset = resolvePreviewAsset(doc); - if (!previewAsset?.url) { - return null; - } - const url = previewAsset.url; - const width = Number(previewAsset?.metadata?.width) || 0; - const height = Number(previewAsset?.metadata?.height) || 0; - const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape'; - return { - id: doc.id, - url, - orientation, - alt: doc.title || doc.original_name || 'Document preview', - }; - }, [resolvePreviewAsset]); + const makePreviewItem = useCallback( + (doc) => { + if (!doc) return null; + const url = resolveDocumentAssetUrl(doc, 'preview', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }); + if (!url) { + return null; + } + const asset = getDocumentAsset(doc, 'preview'); + const width = Number(asset?.metadata?.width) || 0; + const height = Number(asset?.metadata?.height) || 0; + const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape'; + return { + id: doc.id, + url, + orientation, + alt: doc.title || doc.original_name || 'Document preview', + }; + }, + [ensureAssetUrl, getDocumentAsset], + ); const stackDocuments = useMemo(() => { if (!selectedDocuments.length) return []; @@ -1135,24 +1121,12 @@ const DetailPanel = ({ } stackDocuments.forEach((doc) => { - const docId = doc?.id; - if (!docId) { - return; - } - const asset = resolvePreviewAsset(doc); - if (!asset?.id) { - return; - } - const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; - const now = Date.now(); - const needsRefresh = !asset.url || (expiresAt && expiresAt <= now); - if (!needsRefresh) { - return; - } - const force = Boolean(asset.url && expiresAt && expiresAt <= now); - ensureAssetUrl(docId, asset, { force }).catch(() => {}); + resolveDocumentAssetUrl(doc, 'preview', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }); }); - }, [stackDocuments, resolvePreviewAsset, ensureAssetUrl]); + }, [stackDocuments, ensureAssetUrl, getDocumentAsset]); const commonTags = useMemo(() => { if (selectedCount < 2) return []; @@ -2121,7 +2095,8 @@ const AppLayout = () => { setPreviewDocumentId(null); setPreviewDocumentLoading(false); assetManager.reset(); - setPreviewCacheTick(0); + setPreviewEntries(() => new Map()); + previewInflightRef.current = new Map(); dragCounterRef.current = 0; breadcrumbFetchRef.current = new Set(); bootstrapInitializedRef.current = false; @@ -3367,7 +3342,8 @@ const AppLayout = () => { ); const folderPathCacheRef = useRef(new Map()); - const [previewCacheTick, setPreviewCacheTick] = useState(0); + const [previewEntries, setPreviewEntries] = useState(() => new Map()); + const previewInflightRef = useRef(new Map()); const ensureFolderPathOnServer = useCallback( async (baseFolderId, segments) => { @@ -3452,18 +3428,45 @@ const AppLayout = () => { async (documentId, { force = false } = {}) => { if (!documentId) return null; - try { - const entry = await assetManager.ensurePreview(documentId, { force }); - if (entry) { - setPreviewCacheTick((prev) => prev + 1); - } - return entry; - } catch (error) { - notifyApiError(error, 'Unable to fetch document preview.'); - throw error; + const existing = previewEntries.get(documentId) || null; + const now = Date.now(); + const expiresAt = typeof existing?.expiresAt === 'number' ? existing.expiresAt : null; + if (!force && existing && (!expiresAt || expiresAt > now)) { + return existing; } + + if (!force && previewInflightRef.current.has(documentId)) { + return previewInflightRef.current.get(documentId); + } + + const request = (async () => { + try { + const { data } = await api.get(`/documents/${documentId}/download`); + const ttl = data.expires_in ? Math.max(data.expires_in - 60, 30) * 1000 : 5 * 60 * 1000; + const entry = { + url: data.url, + contentType: data.content_type || null, + filename: data.filename, + expiresAt: Date.now() + ttl, + }; + setPreviewEntries((prev) => { + const next = new Map(prev); + next.set(documentId, entry); + return next; + }); + return entry; + } catch (error) { + notifyApiError(error, 'Unable to fetch document preview.'); + throw error; + } finally { + previewInflightRef.current.delete(documentId); + } + })(); + + previewInflightRef.current.set(documentId, request); + return request; }, - [assetManager, notifyApiError], + [previewEntries, notifyApiError], ); const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => { @@ -4771,8 +4774,15 @@ const AppLayout = () => { return changed ? next : prev; }); - assetManager.deleteDocument(documentId); - setPreviewCacheTick((prev) => prev + 1); + setPreviewEntries((prev) => { + if (!prev.has(documentId)) { + return prev; + } + const next = new Map(prev); + next.delete(documentId); + return next; + }); + previewInflightRef.current.delete(documentId); if (selectedDocumentIds.includes(documentId)) { const remaining = selectedDocumentIds.filter((id) => id !== documentId); @@ -4801,8 +4811,8 @@ const AppLayout = () => { setSearchResults, setDocumentDetails, setFolderContents, - assetManager, - setPreviewCacheTick, + setPreviewEntries, + previewInflightRef, previewDocumentId, closeDocumentPreview, selectedDocumentIds, @@ -4951,15 +4961,15 @@ const AppLayout = () => { if (!selectedDocument) { return null; } - return assetManager.getPreview(selectedDocument.id); - }, [assetManager, selectedDocument, previewCacheTick]); + return previewEntries.get(selectedDocument.id) || null; + }, [selectedDocument, previewEntries]); const previewWorkspaceEntry = useMemo(() => { if (!previewDocumentId) { return null; } - return assetManager.getPreview(previewDocumentId); - }, [assetManager, previewDocumentId, previewCacheTick]); + return previewEntries.get(previewDocumentId) || null; + }, [previewDocumentId, previewEntries]); const previewWorkspaceDetail = previewDocumentId ? documentDetails.get(previewDocumentId) @@ -4992,23 +5002,11 @@ const AppLayout = () => { }; const resolveThumbnailUrlForDoc = useCallback( - (doc) => { - if (!doc) { - return null; - } - const asset = getDocumentAsset(doc, 'thumbnail'); - const now = Date.now(); - const assetExpiresAt = typeof asset?.expiresAt === 'number' ? asset.expiresAt : null; - if (asset?.url && (!assetExpiresAt || assetExpiresAt > now)) { - return asset.url; - } - if (doc.id && asset?.id) { - ensureAssetUrl(doc.id, asset, { force: Boolean(assetExpiresAt && assetExpiresAt <= now) }).catch( - () => {}, - ); - } - return null; - }, + (doc) => + resolveDocumentAssetUrl(doc, 'thumbnail', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }), [ensureAssetUrl, getDocumentAsset], ); @@ -5088,7 +5086,6 @@ const AppLayout = () => { onCreateTag: handleTagCreate, onAssignTagToDocument: handleDocumentTagAttach, onRemoveTagFromDocument: handleTagRemove, - resolveAssetPath: resolveApiPath, ensureAssetUrl, getDocumentAsset, }), @@ -5105,7 +5102,6 @@ const AppLayout = () => { handleTagCreate, handleDocumentTagAttach, handleTagRemove, - resolveApiPath, ensureAssetUrl, getDocumentAsset, ], diff --git a/frontend/src/skeuomorphic_ws.css b/frontend/src/skeuomorphic_ws.css index 954eeb6..3ddf0a1 100644 --- a/frontend/src/skeuomorphic_ws.css +++ b/frontend/src/skeuomorphic_ws.css @@ -11,24 +11,8 @@ display: flex; flex-direction: column; min-height: 0; - --desk-1: #f0e2c6; - --desk-2: #e7d2ad; - --desk-3: #d8b98a; - --desk-grain-dark: rgba(110, 86, 48, 0.08); - --desk-grain-fine: rgba(86, 66, 33, 0.04); - --desk-vignette: rgba(60, 40, 18, 0.15); - background: - radial-gradient(120% 85% at 50% -10%, var(--desk-vignette), #0000 48%), - linear-gradient(7deg, #0000 0 32%, var(--desk-grain-dark) 60%, #0000 85%), - repeating-linear-gradient( - 7deg, - var(--desk-grain-fine) 0 2px, - #0000 2px 9px - ), - linear-gradient(90deg, var(--desk-1), var(--desk-2) 48%, var(--desk-3) 100%); - background-size: 100% 100%, 100% 100%, 100% 100%, 100% 100%; - background-blend-mode: multiply, overlay, normal, normal; position: relative; + background: #7a7c7c; } .skeuo-header { @@ -36,7 +20,8 @@ align-items: flex-start; justify-content: space-between; gap: 1rem; - padding: 1rem 1.5rem 0.75rem; + padding: 0.75rem 1rem 0.5rem; + background-color: var(--surface-subtle, rgba(255, 255, 255, 0.08)); } .skeuo-header__meta { @@ -84,13 +69,7 @@ flex: 1; position: relative; overflow: hidden; - margin: 0 1.5rem 1.5rem; - border-radius: 18px; - background-image: radial-gradient(rgba(255, 255, 255, 0.5) 9%, transparent 9%), - linear-gradient(180deg, rgba(0, 0, 0, 0.05), transparent 32%); - background-size: 46px 46px, 100% 100%; - box-shadow: inset 0 16px 28px rgba(0, 0, 0, 0.07), inset 0 -6px 20px rgba(0, 0, 0, 0.05); - backdrop-filter: blur(6px); + margin: 0; } .skeuo-empty { @@ -144,7 +123,7 @@ } .skeuo-item.is-zoomed .skeuo-item__card { - box-shadow: 0 18px 42px rgba(0, 0, 0, 0.24); + box-shadow: 0 18px 80px rgba(0, 0, 0, 0.8); } .skeuo-item.is-tag-target .skeuo-item__card { @@ -290,23 +269,15 @@ body.skeuo-cursor-remove * { .skeuo-item__card { position: relative; - width: 100%; - height: 100%; border-radius: 0; - background: var(--surface); - border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; - box-shadow: 0 12px 36px rgba(0, 0, 0, 0.24); + box-shadow: 0 12px 72px rgba(0, 0, 0, 0.25); overflow: hidden; } .skeuo-item__card img { - width: 100%; - height: 100%; - object-fit: contain; - display: block; } .skeuo-item__placeholder { diff --git a/frontend/src/skeuomorphic_ws.jsx b/frontend/src/skeuomorphic_ws.jsx index 9f4ff7e..446d7ae 100644 --- a/frontend/src/skeuomorphic_ws.jsx +++ b/frontend/src/skeuomorphic_ws.jsx @@ -6,6 +6,7 @@ import React, { useRef, useState, } from 'react'; +import { resolveDocumentAssetUrl } from './asset_manager'; import './skeuomorphic_ws.css'; const ITEM_WIDTH = 220; @@ -258,31 +259,6 @@ const useDocumentDrag = ({ }; }; -const resolvePreviewDimensions = (doc) => { - if (!doc) return null; - - const group = doc?.current_version?.assets || doc?.assets; - let asset = null; - if (group && !Array.isArray(group) && group?.preview) { - asset = group.preview; - } else if (Array.isArray(group)) { - asset = group.find((entry) => entry?.asset_type === 'preview') || null; - } - - if (!asset) { - return null; - } - - const width = asset?.metadata?.width; - const height = asset?.metadata?.height; - - if (Number.isFinite(width) && Number.isFinite(height)) { - return { width, height }; - } - - return null; -}; - const seededRandom = (input) => { const text = String(input); let hash = 2166136261; @@ -336,37 +312,6 @@ const getContrastingTextColor = (hex) => { return luminance > 0.6 ? '#1b1f24' : '#ffffff'; }; -const absolutizeAssetUrl = (input, resolver) => { - if (!input) return null; - if (/^(?:https?:|data:|blob:)/i.test(input)) { - return input; - } - if (typeof resolver === 'function') { - return resolver(input); - } - return input; -}; - -const resolvePreviewUrl = (doc, resolver) => { - if (!doc) return null; - const group = doc?.current_version?.assets || doc?.assets; - let asset = null; - if (group && !Array.isArray(group) && group?.preview) { - asset = group.preview; - } else if (Array.isArray(group)) { - asset = group.find((entry) => entry?.asset_type === 'preview') || null; - } - if (!asset?.url) { - return null; - } - const now = Date.now(); - const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; - if (expiresAt && expiresAt <= now) { - return null; - } - return absolutizeAssetUrl(asset.url, resolver); -}; - const SkeuomorphicWorkspace = ({ documents = [], searchResults = null, @@ -380,13 +325,13 @@ const SkeuomorphicWorkspace = ({ onCreateTag = null, onAssignTagToDocument = null, onRemoveTagFromDocument = null, - resolveAssetPath = null, ensureAssetUrl = null, getDocumentAsset = () => null, }) => { const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); const showingSearchResults = searchResults !== null; + const containerRef = useRef(null); const layoutRef = useRef(new Map()); const itemRefs = useRef(new Map()); @@ -406,47 +351,51 @@ const SkeuomorphicWorkspace = ({ const docSizeMapRef = useRef(new Map()); const removalCursorActiveRef = useRef(false); + const resolvePreviewAsset = useCallback( + (doc) => { + if (!doc) return null; + return getDocumentAsset(doc, 'preview'); + }, + [getDocumentAsset], + ); + + const resolvePreviewDimensions = useCallback( + (doc) => { + if (!doc) return null; + const asset = resolvePreviewAsset(doc); + const width = asset?.metadata?.width; + const height = asset?.metadata?.height; + if (typeof width === 'number' && typeof height === 'number') { + return { width, height }; + } + return null; + }, + [resolvePreviewAsset], + ); + + const resolvePreviewUrl = useCallback( + (doc) => { + if (!doc) return null; + return resolveDocumentAssetUrl(doc, 'preview', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }); + }, + [ensureAssetUrl, getDocumentAsset], + ); + useEffect(() => { if (!ensureAssetUrl) { return; } - const now = Date.now(); - const seen = new Set(); - const queueAsset = (docId, asset) => { - if (!docId || !asset?.id || seen.has(asset.id)) { - return; - } - const type = asset.asset_type; - if (type && type !== 'thumbnail' && type !== 'preview') { - return; - } - const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; - const hasFreshUrl = asset.url && (!expiresAt || expiresAt > now); - if (hasFreshUrl) { - return; - } - const force = Boolean(asset.url && expiresAt && expiresAt <= now); - seen.add(asset.id); - ensureAssetUrl(docId, asset, { force }).catch(() => {}); - }; - items.forEach((doc) => { - const docId = doc?.id; - if (!docId) { - return; - } - - const previewAsset = getDocumentAsset(doc, 'preview'); - if (previewAsset) { - queueAsset(docId, previewAsset); - } - const thumbnailAsset = getDocumentAsset(doc, 'thumbnail'); - if (thumbnailAsset) { - queueAsset(docId, thumbnailAsset); - } + resolveDocumentAssetUrl(doc, 'preview', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }); }); - }, [items, ensureAssetUrl]); + }, [items, ensureAssetUrl, getDocumentAsset]); const focusCanvas = useCallback(() => { const canvas = containerRef.current; @@ -656,7 +605,7 @@ const SkeuomorphicWorkspace = ({ const size = { width, height }; docSizeMapRef.current.set(key, size); return size; - }, []); + }, [resolvePreviewDimensions]); const documentLookup = useMemo(() => { const map = new Map(); @@ -683,8 +632,8 @@ const SkeuomorphicWorkspace = ({ }, [activeShelfTagId, availableTags]); -const resolveZoomMetrics = useCallback( - (doc, cardWidth, cardHeight) => { + const resolveZoomMetrics = useCallback( + (doc, cardWidth, cardHeight) => { const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH; const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT; const safeCardWidth = cardWidth || CARD_WIDTH_MIN; @@ -745,7 +694,7 @@ const resolveZoomMetrics = useCallback( zoomCenterY, }; }, - [canvasSize.width, canvasSize.height], + [canvasSize.width, canvasSize.height, resolvePreviewDimensions], ); const resolveBaseMetrics = useCallback( @@ -769,7 +718,7 @@ const resolveZoomMetrics = useCallback( baseScale: 1, }; }, - [], + [resolvePreviewDimensions], ); const syncLayoutSnapshot = useCallback(() => { @@ -1396,7 +1345,7 @@ const resolveZoomMetrics = useCallback(
0 ? zoomScale : 1; const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1; const tagsStyle = { '--tag-scale': inverseTagScale }; - const previewUrl = resolvePreviewUrl(doc, resolveAssetPath); + const previewUrl = resolvePreviewUrl(doc); const imageUrl = previewUrl; const title = doc.title || doc.original_name || 'Document'; const dragging = draggingId === doc.id;