7
This commit is contained in:
@@ -53,13 +53,13 @@ pub struct Document {
|
|||||||
pub original_name: String,
|
pub original_name: String,
|
||||||
pub content_type: Option<String>,
|
pub content_type: Option<String>,
|
||||||
pub folder_id: Option<Uuid>,
|
pub folder_id: Option<Uuid>,
|
||||||
pub current_version_id: Uuid,
|
|
||||||
pub uploaded_at: NaiveDateTime,
|
pub uploaded_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
pub deleted_at: Option<NaiveDateTime>,
|
pub deleted_at: Option<NaiveDateTime>,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub issued_at: Option<NaiveDateTime>,
|
pub issued_at: Option<NaiveDateTime>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
pub current_version_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
|
|||||||
@@ -17,14 +17,34 @@ export const getAssetFromVersion = (currentVersion, assetType) => {
|
|||||||
return getAssetFromGroup(currentVersion.assets, 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 {
|
class AssetManager {
|
||||||
constructor({ api, assetPresignTtlMs }) {
|
constructor({ api, assetPresignTtlMs }) {
|
||||||
this.api = api;
|
this.api = api;
|
||||||
this.assetPresignTtlMs = assetPresignTtlMs;
|
this.assetPresignTtlMs = assetPresignTtlMs;
|
||||||
this.assetCache = new Map();
|
this.assetCache = new Map();
|
||||||
this.assetInflight = new Map();
|
this.assetInflight = new Map();
|
||||||
this.previewCache = new Map();
|
|
||||||
this.previewInflight = new Map();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setApi(api) {
|
setApi(api) {
|
||||||
@@ -197,60 +217,9 @@ class AssetManager {
|
|||||||
return request;
|
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() {
|
reset() {
|
||||||
this.assetCache.clear();
|
this.assetCache.clear();
|
||||||
this.assetInflight.clear();
|
this.assetInflight.clear();
|
||||||
this.previewCache.clear();
|
|
||||||
this.previewInflight.clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+88
-92
@@ -21,7 +21,7 @@ import {
|
|||||||
matchPath,
|
matchPath,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
import AssetManager, { getAssetFromVersion } from './asset_manager';
|
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager';
|
||||||
import useApiError from './hooks/useApiError';
|
import useApiError from './hooks/useApiError';
|
||||||
import SkeuomorphicWorkspace from './skeuomorphic_ws';
|
import SkeuomorphicWorkspace from './skeuomorphic_ws';
|
||||||
|
|
||||||
@@ -502,25 +502,20 @@ const FilterBar = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const DocumentThumbnailImage = ({ documentId, asset, ensureAssetUrl, alt }) => {
|
const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
|
||||||
useEffect(() => {
|
const url = useMemo(
|
||||||
if (!documentId || !asset?.id || !ensureAssetUrl) {
|
() =>
|
||||||
return;
|
resolveDocumentAssetUrl(document, 'thumbnail', {
|
||||||
}
|
ensureAssetUrl,
|
||||||
const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
|
getAsset: getDocumentAsset,
|
||||||
const now = Date.now();
|
}),
|
||||||
const needsRefresh = !asset?.url || (expiresAt && expiresAt <= now);
|
[document, ensureAssetUrl, getDocumentAsset],
|
||||||
if (!needsRefresh) {
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const force = Boolean(asset.url && expiresAt && expiresAt <= now);
|
|
||||||
ensureAssetUrl(documentId, asset, { force }).catch(() => {});
|
|
||||||
}, [documentId, asset?.id, asset?.url, asset?.expiresAt, ensureAssetUrl]);
|
|
||||||
|
|
||||||
if (asset?.url) {
|
if (url) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={asset.url}
|
src={url}
|
||||||
alt={alt || ''}
|
alt={alt || ''}
|
||||||
className="document-thumbnail"
|
className="document-thumbnail"
|
||||||
/>
|
/>
|
||||||
@@ -567,10 +562,6 @@ const DocumentsTable = ({
|
|||||||
const showingSearchResults = searchResults !== null;
|
const showingSearchResults = searchResults !== null;
|
||||||
const rows = showingSearchResults ? searchResults : documents;
|
const rows = showingSearchResults ? searchResults : documents;
|
||||||
|
|
||||||
const resolveThumbnailAsset = useCallback((doc) => {
|
|
||||||
if (!doc) return null;
|
|
||||||
return getDocumentAsset(doc, 'thumbnail');
|
|
||||||
}, [getDocumentAsset]);
|
|
||||||
const selectedSet = useMemo(
|
const selectedSet = useMemo(
|
||||||
() => new Set(selectedDocumentIds),
|
() => new Set(selectedDocumentIds),
|
||||||
[selectedDocumentIds],
|
[selectedDocumentIds],
|
||||||
@@ -775,8 +766,6 @@ const DocumentsTable = ({
|
|||||||
rowClasses.push('dragging');
|
rowClasses.push('dragging');
|
||||||
}
|
}
|
||||||
|
|
||||||
const thumbnailAsset = resolveThumbnailAsset(doc);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
@@ -799,16 +788,12 @@ const DocumentsTable = ({
|
|||||||
onDragEnd={onDocumentDragEnd}
|
onDragEnd={onDocumentDragEnd}
|
||||||
>
|
>
|
||||||
<td className="thumb-cell">
|
<td className="thumb-cell">
|
||||||
{thumbnailAsset ? (
|
|
||||||
<DocumentThumbnailImage
|
<DocumentThumbnailImage
|
||||||
documentId={doc.id}
|
document={doc}
|
||||||
asset={thumbnailAsset}
|
|
||||||
ensureAssetUrl={ensureAssetUrl}
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className="thumb-placeholder">DOC</div>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="doc-name">
|
<div className="doc-name">
|
||||||
@@ -1076,20 +1061,19 @@ const DetailPanel = ({
|
|||||||
[onPromoteSelection],
|
[onPromoteSelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolvePreviewAsset = useCallback((doc) => {
|
const makePreviewItem = useCallback(
|
||||||
|
(doc) => {
|
||||||
if (!doc) return null;
|
if (!doc) return null;
|
||||||
return getDocumentAsset(doc, 'preview');
|
const url = resolveDocumentAssetUrl(doc, 'preview', {
|
||||||
}, [getDocumentAsset]);
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
const makePreviewItem = useCallback((doc) => {
|
});
|
||||||
if (!doc) return null;
|
if (!url) {
|
||||||
const previewAsset = resolvePreviewAsset(doc);
|
|
||||||
if (!previewAsset?.url) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const url = previewAsset.url;
|
const asset = getDocumentAsset(doc, 'preview');
|
||||||
const width = Number(previewAsset?.metadata?.width) || 0;
|
const width = Number(asset?.metadata?.width) || 0;
|
||||||
const height = Number(previewAsset?.metadata?.height) || 0;
|
const height = Number(asset?.metadata?.height) || 0;
|
||||||
const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape';
|
const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape';
|
||||||
return {
|
return {
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
@@ -1097,7 +1081,9 @@ const DetailPanel = ({
|
|||||||
orientation,
|
orientation,
|
||||||
alt: doc.title || doc.original_name || 'Document preview',
|
alt: doc.title || doc.original_name || 'Document preview',
|
||||||
};
|
};
|
||||||
}, [resolvePreviewAsset]);
|
},
|
||||||
|
[ensureAssetUrl, getDocumentAsset],
|
||||||
|
);
|
||||||
|
|
||||||
const stackDocuments = useMemo(() => {
|
const stackDocuments = useMemo(() => {
|
||||||
if (!selectedDocuments.length) return [];
|
if (!selectedDocuments.length) return [];
|
||||||
@@ -1135,24 +1121,12 @@ const DetailPanel = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
stackDocuments.forEach((doc) => {
|
stackDocuments.forEach((doc) => {
|
||||||
const docId = doc?.id;
|
resolveDocumentAssetUrl(doc, 'preview', {
|
||||||
if (!docId) {
|
ensureAssetUrl,
|
||||||
return;
|
getAsset: getDocumentAsset,
|
||||||
}
|
|
||||||
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(() => {});
|
|
||||||
});
|
});
|
||||||
}, [stackDocuments, resolvePreviewAsset, ensureAssetUrl]);
|
});
|
||||||
|
}, [stackDocuments, ensureAssetUrl, getDocumentAsset]);
|
||||||
|
|
||||||
const commonTags = useMemo(() => {
|
const commonTags = useMemo(() => {
|
||||||
if (selectedCount < 2) return [];
|
if (selectedCount < 2) return [];
|
||||||
@@ -2121,7 +2095,8 @@ const AppLayout = () => {
|
|||||||
setPreviewDocumentId(null);
|
setPreviewDocumentId(null);
|
||||||
setPreviewDocumentLoading(false);
|
setPreviewDocumentLoading(false);
|
||||||
assetManager.reset();
|
assetManager.reset();
|
||||||
setPreviewCacheTick(0);
|
setPreviewEntries(() => new Map());
|
||||||
|
previewInflightRef.current = new Map();
|
||||||
dragCounterRef.current = 0;
|
dragCounterRef.current = 0;
|
||||||
breadcrumbFetchRef.current = new Set();
|
breadcrumbFetchRef.current = new Set();
|
||||||
bootstrapInitializedRef.current = false;
|
bootstrapInitializedRef.current = false;
|
||||||
@@ -3367,7 +3342,8 @@ const AppLayout = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const folderPathCacheRef = useRef(new Map());
|
const folderPathCacheRef = useRef(new Map());
|
||||||
const [previewCacheTick, setPreviewCacheTick] = useState(0);
|
const [previewEntries, setPreviewEntries] = useState(() => new Map());
|
||||||
|
const previewInflightRef = useRef(new Map());
|
||||||
|
|
||||||
const ensureFolderPathOnServer = useCallback(
|
const ensureFolderPathOnServer = useCallback(
|
||||||
async (baseFolderId, segments) => {
|
async (baseFolderId, segments) => {
|
||||||
@@ -3452,18 +3428,45 @@ const AppLayout = () => {
|
|||||||
async (documentId, { force = false } = {}) => {
|
async (documentId, { force = false } = {}) => {
|
||||||
if (!documentId) return null;
|
if (!documentId) return null;
|
||||||
|
|
||||||
try {
|
const existing = previewEntries.get(documentId) || null;
|
||||||
const entry = await assetManager.ensurePreview(documentId, { force });
|
const now = Date.now();
|
||||||
if (entry) {
|
const expiresAt = typeof existing?.expiresAt === 'number' ? existing.expiresAt : null;
|
||||||
setPreviewCacheTick((prev) => prev + 1);
|
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;
|
return entry;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyApiError(error, 'Unable to fetch document preview.');
|
notifyApiError(error, 'Unable to fetch document preview.');
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
previewInflightRef.current.delete(documentId);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
previewInflightRef.current.set(documentId, request);
|
||||||
|
return request;
|
||||||
},
|
},
|
||||||
[assetManager, notifyApiError],
|
[previewEntries, notifyApiError],
|
||||||
);
|
);
|
||||||
|
|
||||||
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
|
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
|
||||||
@@ -4771,8 +4774,15 @@ const AppLayout = () => {
|
|||||||
return changed ? next : prev;
|
return changed ? next : prev;
|
||||||
});
|
});
|
||||||
|
|
||||||
assetManager.deleteDocument(documentId);
|
setPreviewEntries((prev) => {
|
||||||
setPreviewCacheTick((prev) => prev + 1);
|
if (!prev.has(documentId)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(documentId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
previewInflightRef.current.delete(documentId);
|
||||||
|
|
||||||
if (selectedDocumentIds.includes(documentId)) {
|
if (selectedDocumentIds.includes(documentId)) {
|
||||||
const remaining = selectedDocumentIds.filter((id) => id !== documentId);
|
const remaining = selectedDocumentIds.filter((id) => id !== documentId);
|
||||||
@@ -4801,8 +4811,8 @@ const AppLayout = () => {
|
|||||||
setSearchResults,
|
setSearchResults,
|
||||||
setDocumentDetails,
|
setDocumentDetails,
|
||||||
setFolderContents,
|
setFolderContents,
|
||||||
assetManager,
|
setPreviewEntries,
|
||||||
setPreviewCacheTick,
|
previewInflightRef,
|
||||||
previewDocumentId,
|
previewDocumentId,
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
@@ -4951,15 +4961,15 @@ const AppLayout = () => {
|
|||||||
if (!selectedDocument) {
|
if (!selectedDocument) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return assetManager.getPreview(selectedDocument.id);
|
return previewEntries.get(selectedDocument.id) || null;
|
||||||
}, [assetManager, selectedDocument, previewCacheTick]);
|
}, [selectedDocument, previewEntries]);
|
||||||
|
|
||||||
const previewWorkspaceEntry = useMemo(() => {
|
const previewWorkspaceEntry = useMemo(() => {
|
||||||
if (!previewDocumentId) {
|
if (!previewDocumentId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return assetManager.getPreview(previewDocumentId);
|
return previewEntries.get(previewDocumentId) || null;
|
||||||
}, [assetManager, previewDocumentId, previewCacheTick]);
|
}, [previewDocumentId, previewEntries]);
|
||||||
|
|
||||||
const previewWorkspaceDetail = previewDocumentId
|
const previewWorkspaceDetail = previewDocumentId
|
||||||
? documentDetails.get(previewDocumentId)
|
? documentDetails.get(previewDocumentId)
|
||||||
@@ -4992,23 +5002,11 @@ const AppLayout = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveThumbnailUrlForDoc = useCallback(
|
const resolveThumbnailUrlForDoc = useCallback(
|
||||||
(doc) => {
|
(doc) =>
|
||||||
if (!doc) {
|
resolveDocumentAssetUrl(doc, 'thumbnail', {
|
||||||
return null;
|
ensureAssetUrl,
|
||||||
}
|
getAsset: getDocumentAsset,
|
||||||
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;
|
|
||||||
},
|
|
||||||
[ensureAssetUrl, getDocumentAsset],
|
[ensureAssetUrl, getDocumentAsset],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -5088,7 +5086,6 @@ const AppLayout = () => {
|
|||||||
onCreateTag: handleTagCreate,
|
onCreateTag: handleTagCreate,
|
||||||
onAssignTagToDocument: handleDocumentTagAttach,
|
onAssignTagToDocument: handleDocumentTagAttach,
|
||||||
onRemoveTagFromDocument: handleTagRemove,
|
onRemoveTagFromDocument: handleTagRemove,
|
||||||
resolveAssetPath: resolveApiPath,
|
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
}),
|
}),
|
||||||
@@ -5105,7 +5102,6 @@ const AppLayout = () => {
|
|||||||
handleTagCreate,
|
handleTagCreate,
|
||||||
handleDocumentTagAttach,
|
handleDocumentTagAttach,
|
||||||
handleTagRemove,
|
handleTagRemove,
|
||||||
resolveApiPath,
|
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,24 +11,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
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;
|
position: relative;
|
||||||
|
background: #7a7c7c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-header {
|
.skeuo-header {
|
||||||
@@ -36,7 +20,8 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
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 {
|
.skeuo-header__meta {
|
||||||
@@ -84,13 +69,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0 1.5rem 1.5rem;
|
margin: 0;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-empty {
|
.skeuo-empty {
|
||||||
@@ -144,7 +123,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item.is-zoomed .skeuo-item__card {
|
.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 {
|
.skeuo-item.is-tag-target .skeuo-item__card {
|
||||||
@@ -290,23 +269,15 @@ body.skeuo-cursor-remove * {
|
|||||||
|
|
||||||
.skeuo-item__card {
|
.skeuo-item__card {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item__card img {
|
.skeuo-item__card img {
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item__placeholder {
|
.skeuo-item__placeholder {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { resolveDocumentAssetUrl } from './asset_manager';
|
||||||
import './skeuomorphic_ws.css';
|
import './skeuomorphic_ws.css';
|
||||||
|
|
||||||
const ITEM_WIDTH = 220;
|
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 seededRandom = (input) => {
|
||||||
const text = String(input);
|
const text = String(input);
|
||||||
let hash = 2166136261;
|
let hash = 2166136261;
|
||||||
@@ -336,37 +312,6 @@ const getContrastingTextColor = (hex) => {
|
|||||||
return luminance > 0.6 ? '#1b1f24' : '#ffffff';
|
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 = ({
|
const SkeuomorphicWorkspace = ({
|
||||||
documents = [],
|
documents = [],
|
||||||
searchResults = null,
|
searchResults = null,
|
||||||
@@ -380,13 +325,13 @@ const SkeuomorphicWorkspace = ({
|
|||||||
onCreateTag = null,
|
onCreateTag = null,
|
||||||
onAssignTagToDocument = null,
|
onAssignTagToDocument = null,
|
||||||
onRemoveTagFromDocument = null,
|
onRemoveTagFromDocument = null,
|
||||||
resolveAssetPath = null,
|
|
||||||
ensureAssetUrl = null,
|
ensureAssetUrl = null,
|
||||||
getDocumentAsset = () => null,
|
getDocumentAsset = () => null,
|
||||||
}) => {
|
}) => {
|
||||||
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
|
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
|
||||||
const showingSearchResults = searchResults !== null;
|
const showingSearchResults = searchResults !== null;
|
||||||
|
|
||||||
|
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const layoutRef = useRef(new Map());
|
const layoutRef = useRef(new Map());
|
||||||
const itemRefs = useRef(new Map());
|
const itemRefs = useRef(new Map());
|
||||||
@@ -406,47 +351,51 @@ const SkeuomorphicWorkspace = ({
|
|||||||
const docSizeMapRef = useRef(new Map());
|
const docSizeMapRef = useRef(new Map());
|
||||||
const removalCursorActiveRef = useRef(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!ensureAssetUrl) {
|
if (!ensureAssetUrl) {
|
||||||
return;
|
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) => {
|
items.forEach((doc) => {
|
||||||
const docId = doc?.id;
|
resolveDocumentAssetUrl(doc, 'preview', {
|
||||||
if (!docId) {
|
ensureAssetUrl,
|
||||||
return;
|
getAsset: getDocumentAsset,
|
||||||
}
|
|
||||||
|
|
||||||
const previewAsset = getDocumentAsset(doc, 'preview');
|
|
||||||
if (previewAsset) {
|
|
||||||
queueAsset(docId, previewAsset);
|
|
||||||
}
|
|
||||||
const thumbnailAsset = getDocumentAsset(doc, 'thumbnail');
|
|
||||||
if (thumbnailAsset) {
|
|
||||||
queueAsset(docId, thumbnailAsset);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, [items, ensureAssetUrl]);
|
});
|
||||||
|
}, [items, ensureAssetUrl, getDocumentAsset]);
|
||||||
|
|
||||||
const focusCanvas = useCallback(() => {
|
const focusCanvas = useCallback(() => {
|
||||||
const canvas = containerRef.current;
|
const canvas = containerRef.current;
|
||||||
@@ -656,7 +605,7 @@ const SkeuomorphicWorkspace = ({
|
|||||||
const size = { width, height };
|
const size = { width, height };
|
||||||
docSizeMapRef.current.set(key, size);
|
docSizeMapRef.current.set(key, size);
|
||||||
return size;
|
return size;
|
||||||
}, []);
|
}, [resolvePreviewDimensions]);
|
||||||
|
|
||||||
const documentLookup = useMemo(() => {
|
const documentLookup = useMemo(() => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
@@ -683,7 +632,7 @@ const SkeuomorphicWorkspace = ({
|
|||||||
}, [activeShelfTagId, availableTags]);
|
}, [activeShelfTagId, availableTags]);
|
||||||
|
|
||||||
|
|
||||||
const resolveZoomMetrics = useCallback(
|
const resolveZoomMetrics = useCallback(
|
||||||
(doc, cardWidth, cardHeight) => {
|
(doc, cardWidth, cardHeight) => {
|
||||||
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
|
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
|
||||||
const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT;
|
const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT;
|
||||||
@@ -745,7 +694,7 @@ const resolveZoomMetrics = useCallback(
|
|||||||
zoomCenterY,
|
zoomCenterY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[canvasSize.width, canvasSize.height],
|
[canvasSize.width, canvasSize.height, resolvePreviewDimensions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolveBaseMetrics = useCallback(
|
const resolveBaseMetrics = useCallback(
|
||||||
@@ -769,7 +718,7 @@ const resolveZoomMetrics = useCallback(
|
|||||||
baseScale: 1,
|
baseScale: 1,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[],
|
[resolvePreviewDimensions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const syncLayoutSnapshot = useCallback(() => {
|
const syncLayoutSnapshot = useCallback(() => {
|
||||||
@@ -1396,7 +1345,7 @@ const resolveZoomMetrics = useCallback(
|
|||||||
<div
|
<div
|
||||||
className="skeuo-canvas"
|
className="skeuo-canvas"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
tabIndex={0}
|
tabIndex={1}
|
||||||
data-active-tag={activeShelfTagId || undefined}
|
data-active-tag={activeShelfTagId || undefined}
|
||||||
onDragOver={handleCanvasDragOver}
|
onDragOver={handleCanvasDragOver}
|
||||||
onDragLeave={handleCanvasDragLeave}
|
onDragLeave={handleCanvasDragLeave}
|
||||||
@@ -1455,7 +1404,7 @@ const resolveZoomMetrics = useCallback(
|
|||||||
const totalScale = zoomScale > 0 ? zoomScale : 1;
|
const totalScale = zoomScale > 0 ? zoomScale : 1;
|
||||||
const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1;
|
const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1;
|
||||||
const tagsStyle = { '--tag-scale': inverseTagScale };
|
const tagsStyle = { '--tag-scale': inverseTagScale };
|
||||||
const previewUrl = resolvePreviewUrl(doc, resolveAssetPath);
|
const previewUrl = resolvePreviewUrl(doc);
|
||||||
const imageUrl = previewUrl;
|
const imageUrl = previewUrl;
|
||||||
const title = doc.title || doc.original_name || 'Document';
|
const title = doc.title || doc.original_name || 'Document';
|
||||||
const dragging = draggingId === doc.id;
|
const dragging = draggingId === doc.id;
|
||||||
|
|||||||
Reference in New Issue
Block a user