frontend: multi-page assets
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 26s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 56s

This commit is contained in:
2025-10-20 21:59:58 +02:00
parent 1859078cf4
commit 047b99e2aa
9 changed files with 841 additions and 124 deletions
+196 -21
View File
@@ -17,7 +17,116 @@ export const getAssetFromVersion = (currentVersion, assetType) => {
return getAssetFromGroup(currentVersion.assets, assetType); return getAssetFromGroup(currentVersion.assets, assetType);
}; };
export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, ensureOptions } = {}) => { const normalizeAssetObjects = (objects) => {
if (!Array.isArray(objects)) {
return [];
}
return objects
.filter((entry) => Number.isInteger(entry?.ordinal))
.slice()
.sort((a, b) => a.ordinal - b.ordinal);
};
const mergeAssetObjects = (existingObjects, incomingObjects) => {
const merged = new Map();
normalizeAssetObjects(existingObjects).forEach((entry) => {
merged.set(entry.ordinal, { ...entry });
});
normalizeAssetObjects(incomingObjects).forEach((entry) => {
const current = merged.get(entry.ordinal) || {};
merged.set(entry.ordinal, { ...current, ...entry });
});
return [...merged.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, value]) => value);
};
export class AssetView {
constructor(asset) {
this.asset = asset || null;
this._objectsRef = null;
this._sortedObjects = [];
}
getCardinality() {
if (!this.asset) {
return 0;
}
const reported = Number(this.asset.cardinality);
if (Number.isFinite(reported) && reported > 0) {
return reported;
}
const objectsCount = this.getObjects().length;
if (objectsCount > 0) {
return objectsCount;
}
return this.asset.metadata ? 1 : 0;
}
getObjects() {
if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) {
return [];
}
if (this._objectsRef === this.asset.objects) {
return this._sortedObjects;
}
this._objectsRef = this.asset.objects;
this._sortedObjects = normalizeAssetObjects(this.asset.objects);
return this._sortedObjects;
}
getObject(ordinal = 1) {
const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal);
if (fromObjects) {
return fromObjects;
}
if (ordinal === 1 && this.asset) {
if (this.asset.url || this.asset.metadata) {
return {
ordinal: 1,
url: this.asset.url || null,
metadata: this.asset.metadata || null,
expires_at: this.asset.expiresAt ?? null,
};
}
}
return null;
}
getPrimaryObject() {
return this.getObject(1);
}
getPrimaryMetadata() {
return this.getPrimaryObject()?.metadata || null;
}
getPrimaryUrl() {
return this.getPrimaryObject()?.url || null;
}
hasObject(ordinal) {
return Boolean(this.getObject(ordinal));
}
}
export const createAssetView = (asset) => new AssetView(asset);
export const resolveDocumentAssetUrl = (
doc,
type,
{ ensureAssetUrl, getAsset, ensureOptions, objectOrdinal = 1 } = {},
) => {
if (!doc || !type) { if (!doc || !type) {
return null; return null;
} }
@@ -25,15 +134,32 @@ export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, e
if (!asset) { if (!asset) {
return null; return null;
} }
const view = createAssetView(asset);
const object = view.getObject(objectOrdinal);
const url = object?.url || (objectOrdinal === 1 ? view.getPrimaryUrl() : null);
const expiresAt = typeof object?.expires_at === 'number'
? object.expires_at
: objectOrdinal === 1 && typeof asset.expiresAt === 'number'
? asset.expiresAt
: null;
const now = Date.now(); const now = Date.now();
const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; if (url && (!expiresAt || expiresAt > now)) {
const hasFreshUrl = asset.url && (!expiresAt || expiresAt > now); return url;
if (hasFreshUrl) {
return asset.url;
} }
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') { if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
const force = Boolean(asset.url && expiresAt && expiresAt <= now); const force = Boolean(url && expiresAt && expiresAt <= now);
const options = ensureOptions ? { ...ensureOptions, force } : { force }; const options = {
force,
start: objectOrdinal,
limit: 1,
...(ensureOptions || {}),
};
if (!options.start) {
options.start = objectOrdinal;
}
if (!options.limit) {
options.limit = 1;
}
ensureAssetUrl(doc.id, asset, options).catch(() => {}); ensureAssetUrl(doc.id, asset, options).catch(() => {});
} }
return null; return null;
@@ -63,6 +189,10 @@ class AssetManager {
} }
const cached = this.assetCache.get(asset.id); const cached = this.assetCache.get(asset.id);
if (!cached) { if (!cached) {
const normalized = mergeAssetObjects(null, asset.objects);
if (normalized.length) {
return { ...asset, objects: normalized };
}
return asset; return asset;
} }
const merged = { ...cached, ...asset }; const merged = { ...cached, ...asset };
@@ -76,6 +206,10 @@ class AssetManager {
merged.expiresAt = cachedExpires; merged.expiresAt = cachedExpires;
} }
} }
const mergedObjects = mergeAssetObjects(cached.objects, asset.objects);
if (mergedObjects.length) {
merged.objects = mergedObjects;
}
return merged; return merged;
} }
@@ -177,16 +311,45 @@ class AssetManager {
return Promise.resolve(asset || null); return Promise.resolve(asset || null);
} }
const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; const requestedStart = Number.isInteger(start) && start > 0 ? start : 1;
if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) { const requestedLimit = Number.isInteger(limit) && limit > 0 ? limit : 1;
this.rememberAsset(asset); const requestedEnd = requestedStart + requestedLimit - 1;
return Promise.resolve(asset);
const baseAsset = this.assetCache.get(asset.id) || asset;
const view = createAssetView(baseAsset);
const assetExpiresAt = typeof baseAsset.expiresAt === 'number' ? baseAsset.expiresAt : null;
const now = Date.now();
const isOrdinalSatisfied = (ordinal) => {
const object = view.getObject(ordinal);
if (!object) {
return false;
}
if (!object.url) {
return false;
}
if (typeof object.expires_at === 'number') {
return object.expires_at > now;
}
if (ordinal === 1 && baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) {
return true;
}
return true;
};
let needsFetch = force;
if (!needsFetch) {
for (let ordinal = requestedStart; ordinal <= requestedEnd; ordinal += 1) {
if (!isOrdinalSatisfied(ordinal)) {
needsFetch = true;
break;
}
}
} }
const cached = this.assetCache.get(asset.id); if (!needsFetch) {
const now = Date.now(); this.rememberAsset(baseAsset);
if (!force && cached && cached.expiresAt && cached.expiresAt > now && cached.url) { return Promise.resolve(baseAsset);
return Promise.resolve({ ...asset, ...cached });
} }
const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`; const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`;
@@ -211,19 +374,31 @@ class AssetManager {
const request = this.api const request = this.api
.get(`/assets/${asset.id}`, requestConfig) .get(`/assets/${asset.id}`, requestConfig)
.then(({ data }) => { .then(({ data }) => {
const objects = Array.isArray(data.objects) ? data.objects : []; const incomingObjects = Array.isArray(data.objects) ? data.objects : [];
const primaryObject = objects[0] || null; const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
const mergedObjects = mergeAssetObjects(cachedEntry?.objects, incomingObjects);
const combined = { ...cachedEntry, ...asset, ...data, objects: mergedObjects };
const view = createAssetView(combined);
const primaryObject = view.getPrimaryObject();
const expiresAt = typeof primaryObject?.expires_at === 'number' const expiresAt = typeof primaryObject?.expires_at === 'number'
? primaryObject.expires_at ? primaryObject.expires_at
: Date.now() + this.assetPresignTtlMs; : Date.now() + this.assetPresignTtlMs;
const cardinality = (() => {
const reported = Number(data.cardinality ?? asset.cardinality ?? cachedEntry?.cardinality);
const objectsCount = mergedObjects.length;
if (Number.isFinite(reported) && reported > 0) {
return Math.max(reported, objectsCount) || null;
}
return objectsCount || null;
})();
const entry = { const entry = {
...asset, ...combined,
...data, cardinality,
objects, url: view.getPrimaryUrl(),
url: primaryObject?.url || null,
expiresAt, expiresAt,
}; };
this.rememberAsset(entry); this.rememberAsset(entry);
return entry; return entry;
}) })
+193 -37
View File
@@ -1,15 +1,25 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { DownloadIcon, EditIcon } from '../ui/icons'; import { DownloadIcon, EditIcon, ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { formatFileSize } from '../utils/format'; import { formatFileSize } from '../utils/format';
import { resolveDocumentAssetUrl } from '../asset_manager'; import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
import { CORRESPONDENT_ROLES } from '../constants/correspondents'; import { CORRESPONDENT_ROLES } from '../constants/correspondents';
const MAX_PREVIEW_STACK_ITEMS = 15; const MAX_PREVIEW_STACK_ITEMS = 15;
const normalizeRole = (role) => (role || '').toLowerCase(); const normalizeRole = (role) => (role || '').toLowerCase();
const derivePreviewOrientation = (metadata) => {
const width = Number(metadata?.width);
const height = Number(metadata?.height);
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
return width >= height ? 'landscape' : 'portrait';
}
return 'landscape';
};
const formatRoleLabel = (role) => { const formatRoleLabel = (role) => {
const normalized = normalizeRole(role); const normalized = normalizeRole(role);
if (!normalized) return 'Other'; if (!normalized) return 'Other';
@@ -435,22 +445,34 @@ const DetailPanel = ({
setOcrOpen(false); setOcrOpen(false);
}, []); }, []);
const makePreviewItem = useCallback( const singlePreviewNavigator = useAssetNavigator({
(doc) => { document: singleDoc,
if (!doc) return null; assetType: 'preview',
const url = resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl, ensureAssetUrl,
getAsset: getDocumentAsset, getAsset: getDocumentAsset,
prefetch: 3,
}); });
const makePreviewItem = useCallback(
(doc, ordinal = 1) => {
if (!doc) return null;
const asset = getDocumentAsset(doc, 'preview');
const assetView = createAssetView(asset);
const object = assetView.getObject(ordinal);
let url = object?.url || null;
if (!url) {
url = resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl,
getAsset: getDocumentAsset,
ensureOptions: { start: ordinal, limit: 1 },
objectOrdinal: ordinal,
});
}
if (!url) { if (!url) {
return null; return null;
} }
const asset = getDocumentAsset(doc, 'preview'); const metadata = object?.metadata || assetView.getPrimaryMetadata() || {};
const primaryObject = asset?.objects?.[0] || null; const orientation = derivePreviewOrientation(metadata);
const primaryMetadata = primaryObject?.metadata || asset?.metadata || {};
const width = Number(primaryMetadata?.width) || 0;
const height = Number(primaryMetadata?.height) || 0;
const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape';
return { return {
id: doc.id, id: doc.id,
url, url,
@@ -477,32 +499,74 @@ const DetailPanel = ({
return ordered; return ordered;
}, [selectedDocuments]); }, [selectedDocuments]);
const singlePreviewItems = useMemo(() => { const stackTopDocument = stackDocuments[0] || null;
if (!singleDoc) return []; const stackPreviewNavigator = useAssetNavigator({
const item = makePreviewItem(singleDoc); document: stackTopDocument,
return item ? [item] : []; assetType: 'preview',
}, [singleDoc, makePreviewItem]);
const stackPreviews = useMemo(
() =>
stackDocuments
.map((doc) => makePreviewItem(doc))
.filter(Boolean),
[stackDocuments, makePreviewItem],
);
useEffect(() => {
if (!ensureAssetUrl) {
return;
}
stackDocuments.forEach((doc) => {
resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl, ensureAssetUrl,
getAsset: getDocumentAsset, getAsset: getDocumentAsset,
prefetch: 3,
}); });
});
}, [stackDocuments, ensureAssetUrl, getDocumentAsset]); const singlePreviewItems = useMemo(() => {
if (!singleDoc) return [];
const url = singlePreviewNavigator.currentUrl;
if (!url) {
return [];
}
const orientation = derivePreviewOrientation(singlePreviewNavigator.currentMetadata);
return [
{
id: singleDoc.id,
url,
orientation,
alt: singleDoc.title || singleDoc.original_name || 'Document preview',
},
];
}, [singleDoc, singlePreviewNavigator.currentUrl, singlePreviewNavigator.currentMetadata]);
const stackPreviews = useMemo(() => {
if (!stackDocuments.length) {
return [];
}
return stackDocuments
.map((doc) => {
if (!doc) return null;
if (stackTopDocument && doc.id === stackTopDocument.id) {
const url = stackPreviewNavigator.currentUrl;
if (!url) {
return null;
}
const orientation = derivePreviewOrientation(stackPreviewNavigator.currentMetadata);
return {
id: doc.id,
url,
orientation,
alt: doc.title || doc.original_name || 'Document preview',
};
}
return makePreviewItem(doc, 1);
})
.filter(Boolean);
}, [
stackDocuments,
stackTopDocument,
stackPreviewNavigator.currentUrl,
stackPreviewNavigator.currentMetadata,
makePreviewItem,
]);
const singleCardinality = singlePreviewNavigator.cardinality;
const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl);
const topDocId = stackTopDocument?.id || null;
const topCardinality = stackPreviewNavigator.cardinality;
const topEffectiveCardinality = topCardinality || (stackPreviewNavigator.currentUrl ? 1 : 0);
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
const topOrdinal = stackPreviewNavigator.ordinal;
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
const topCanGoNext = stackPreviewNavigator.canGoNext;
const bulkTagUnion = useMemo(() => { const bulkTagUnion = useMemo(() => {
if (!selectedDocuments.length) return []; if (!selectedDocuments.length) return [];
@@ -661,9 +725,17 @@ const DetailPanel = ({
const hasPageCount = Number.isFinite(pageCountValue) && pageCountValue >= 0; const hasPageCount = Number.isFinite(pageCountValue) && pageCountValue >= 0;
const metadata = const metadata =
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null; singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
const effectiveCardinality = singleEffectiveCardinality;
const canGoPrev = singlePreviewNavigator.canGoPrev;
const canGoNext = singlePreviewNavigator.canGoNext;
const hasPreviewImage = singleHasPreview;
const interceptNavPointer = (event) => {
event.preventDefault();
event.stopPropagation();
};
return ( return (
<> <>
<div>
<div className="preview-pane preview-pane--stack"> <div className="preview-pane preview-pane--stack">
<PreviewStack <PreviewStack
items={singlePreviewItems} items={singlePreviewItems}
@@ -673,7 +745,44 @@ const DetailPanel = ({
onOpenPreview={onOpenPreview} onOpenPreview={onOpenPreview}
activeItemId={activePreviewId} activeItemId={activePreviewId}
/> />
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
<div className="preview-pane__nav preview-pane__nav--overlay">
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--prev"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
singlePreviewNavigator.goPrev();
}}
onPointerDown={interceptNavPointer}
onPointerUp={interceptNavPointer}
onMouseDown={interceptNavPointer}
onMouseUp={interceptNavPointer}
disabled={!canGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--next"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
singlePreviewNavigator.goNext();
}}
onPointerDown={interceptNavPointer}
onPointerUp={interceptNavPointer}
onMouseDown={interceptNavPointer}
onMouseUp={interceptNavPointer}
disabled={!canGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div> </div>
) : null}
</div> </div>
<div className="doc-title-row"> <div className="doc-title-row">
{isEditingTitle ? ( {isEditingTitle ? (
@@ -873,10 +982,20 @@ const DetailPanel = ({
const renderBulk = () => { const renderBulk = () => {
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`; const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—'; const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
const topDoc = stackTopDocument;
const topDocIdLocal = topDocId;
const topOrdinal = stackPreviewNavigator.ordinal;
const topCardinalityLocal = topEffectiveCardinality;
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
const topCanGoNext = stackPreviewNavigator.canGoNext;
const interceptTopNavPointer = (event) => {
event.preventDefault();
event.stopPropagation();
};
return ( return (
<> <>
<div>
<div className="preview-pane preview-pane--stack"> <div className="preview-pane preview-pane--stack">
<PreviewStack <PreviewStack
items={stackPreviews} items={stackPreviews}
@@ -885,7 +1004,44 @@ const DetailPanel = ({
onOpenPreview={onOpenPreview} onOpenPreview={onOpenPreview}
activeItemId={activePreviewId} activeItemId={activePreviewId}
/> />
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
<div className="preview-pane__nav preview-pane__nav--overlay">
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--prev"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
stackPreviewNavigator.goPrev();
}}
onPointerDown={interceptTopNavPointer}
onPointerUp={interceptTopNavPointer}
onMouseDown={interceptTopNavPointer}
onMouseUp={interceptTopNavPointer}
disabled={!topCanGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--next"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
stackPreviewNavigator.goNext();
}}
onPointerDown={interceptTopNavPointer}
onPointerUp={interceptTopNavPointer}
onMouseDown={interceptTopNavPointer}
onMouseUp={interceptTopNavPointer}
disabled={!topCanGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div> </div>
) : null}
</div> </div>
<h3 style={{ margin: 0 }}>{countLabel}</h3> <h3 style={{ margin: 0 }}>{countLabel}</h3>
<div className="meta"> <div className="meta">
+7 -4
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { getAssetFromVersion, resolveDocumentAssetUrl } from '../asset_manager'; import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon } from '../ui/icons'; import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon } from '../ui/icons';
@@ -21,9 +21,12 @@ const DocumentThumbnailImage = ({
maxSize = LIST_ICON_SIZE, maxSize = LIST_ICON_SIZE,
}) => { }) => {
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1)); const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
const thumbnailAsset = useMemo(() => getAssetFromVersion(document?.current_version, 'thumbnail'), [document?.current_version]); const thumbnailAsset = useMemo(
const primaryObject = thumbnailAsset?.objects?.[0] || null; () => getAssetFromVersion(document?.current_version, 'thumbnail'),
const primaryMetadata = primaryObject?.metadata || thumbnailAsset?.metadata || {}; [document?.current_version],
);
const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]);
const primaryMetadata = thumbnailView.getPrimaryMetadata() || {};
const assetWidth = Number(primaryMetadata?.width); const assetWidth = Number(primaryMetadata?.width);
const assetHeight = Number(primaryMetadata?.height); const assetHeight = Number(primaryMetadata?.height);
+133
View File
@@ -0,0 +1,133 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { createAssetView } from '../asset_manager';
const clampOrdinalValue = (value, cardinality, defaultOrdinal) => {
const raw = Number.isFinite(value) ? value : defaultOrdinal;
let next = Math.max(1, Math.floor(raw));
if (cardinality && cardinality > 0) {
next = Math.min(next, cardinality);
}
return next;
};
export const useAssetNavigator = ({
document,
assetType,
ensureAssetUrl,
getAsset,
prefetch = 2,
defaultOrdinal = 1,
}) => {
const documentId = document?.id || null;
const asset = useMemo(() => {
if (!document || typeof getAsset !== 'function') {
return null;
}
return getAsset(document, assetType);
}, [document, assetType, getAsset]);
const view = useMemo(() => createAssetView(asset), [asset]);
const cardinality = view.getCardinality();
const [ordinal, setOrdinalInternal] = useState(defaultOrdinal);
useEffect(() => {
setOrdinalInternal(defaultOrdinal);
}, [documentId, assetType, defaultOrdinal]);
const setOrdinal = useCallback(
(next) => {
setOrdinalInternal((prev) => {
const target = typeof next === 'function' ? next(prev) : next;
return clampOrdinalValue(target, cardinality, defaultOrdinal);
});
},
[cardinality, defaultOrdinal],
);
const goPrev = useCallback(() => setOrdinal((value) => value - 1), [setOrdinal]);
const goNext = useCallback(() => setOrdinal((value) => value + 1), [setOrdinal]);
const objects = view.getObjects();
const currentObject = view.getObject(ordinal);
const currentUrl = currentObject?.url || view.getPrimaryUrl();
const currentMetadata = currentObject?.metadata || view.getPrimaryMetadata() || null;
const canGoPrev = ordinal > 1;
const canGoNext = cardinality ? ordinal < cardinality : true;
const ordinalsNeedingLoad = useMemo(() => {
const missing = [];
if (!asset) {
return missing;
}
const maxOrdinal = cardinality && cardinality > 0
? Math.min(cardinality, ordinal + Math.max(1, prefetch) - 1)
: ordinal + Math.max(1, prefetch) - 1;
for (let ord = ordinal; ord <= maxOrdinal; ord += 1) {
const object = view.getObject(ord);
if (!object?.url) {
missing.push(ord);
}
}
return missing;
}, [asset, view, ordinal, prefetch, cardinality]);
const fetchStart = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[0] : null;
const fetchEnd = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[ordinalsNeedingLoad.length - 1] : null;
const fetchLimit = fetchStart && fetchEnd ? fetchEnd - fetchStart + 1 : null;
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!documentId || !asset || !ensureAssetUrl) {
setLoading(false);
return;
}
if (!fetchStart || !fetchLimit) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
ensureAssetUrl(documentId, asset, {
start: fetchStart,
limit: fetchLimit,
})
.catch(() => {})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [documentId, asset, ensureAssetUrl, fetchStart, fetchLimit]);
return {
document,
documentId,
asset,
assetType,
ordinal,
setOrdinal,
goPrev,
goNext,
canGoPrev,
canGoNext,
cardinality,
currentObject,
currentUrl,
currentMetadata,
objects,
isLoading: loading,
};
};
export default useAssetNavigator;
+31 -6
View File
@@ -21,7 +21,7 @@ import {
matchPath, matchPath,
} from 'react-router-dom'; } from 'react-router-dom';
import './styles.css'; import './styles.css';
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager'; import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import useApiError from './hooks/useApiError'; import useApiError from './hooks/useApiError';
import SkeuomorphicWorkspace from './skeuomorphic_ws'; import SkeuomorphicWorkspace from './skeuomorphic_ws';
import DetailPanel from './detail/DetailPanel'; import DetailPanel from './detail/DetailPanel';
@@ -413,8 +413,27 @@ const AppLayout = () => {
const isAssetEquivalent = (lhs, rhs) => { const isAssetEquivalent = (lhs, rhs) => {
if (!lhs || !rhs) return false; if (!lhs || !rhs) return false;
const lhsPrimaryMetadata = lhs?.objects?.[0]?.metadata || lhs?.metadata; const lhsView = createAssetView(lhs);
const rhsPrimaryMetadata = rhs?.objects?.[0]?.metadata || rhs?.metadata; const rhsView = createAssetView(rhs);
const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata;
const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata;
const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null;
const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null;
const lhsObjects = lhsView.getObjects();
const rhsObjects = rhsView.getObjects();
const objectsComparable = lhsObjects.length === rhsObjects.length
&& lhsObjects.every((entry, index) => {
const other = rhsObjects[index];
if (!other) return false;
if (entry.ordinal !== other.ordinal) return false;
if (entry.url && other.url && entry.url === other.url) {
return true;
}
if (!entry.url && !other.url) {
return JSON.stringify(entry.metadata || null) === JSON.stringify(other.metadata || null);
}
return entry.url === other.url;
});
return ( return (
lhs.id === rhs.id && lhs.id === rhs.id &&
lhs.url === rhs.url && lhs.url === rhs.url &&
@@ -422,7 +441,9 @@ const AppLayout = () => {
lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height && lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height &&
lhs.mime_type === rhs.mime_type && lhs.mime_type === rhs.mime_type &&
lhs.asset_type === rhs.asset_type && lhs.asset_type === rhs.asset_type &&
lhs.created_at === rhs.created_at lhs.created_at === rhs.created_at &&
lhsCardinality === rhsCardinality &&
objectsComparable
); );
}; };
@@ -2354,13 +2375,17 @@ const AppLayout = () => {
); );
const ensureAssetUrl = useCallback( const ensureAssetUrl = useCallback(
async (documentId, asset, { force = false } = {}) => { async (documentId, asset, { force = false, start = null, limit = null } = {}) => {
if (!documentId || !asset?.id) { if (!documentId || !asset?.id) {
return null; return null;
} }
try { try {
const entry = await assetManager.ensureAsset(documentId, asset, { force }); const entry = await assetManager.ensureAsset(documentId, asset, {
force,
start,
limit,
});
if (!entry) { if (!entry) {
return null; return null;
+54
View File
@@ -189,6 +189,59 @@
user-select: none; user-select: none;
} }
.skeuo-card__nav {
position: absolute;
bottom: calc(3em * 0.707 * var(--nav-scale));
left: 50%;
transform: translateX(-50%) scale(calc(0.707 * var(--nav-scale, 1)));
transform-origin: center;
display: flex;
gap: 2rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.skeuo-item__card:hover .skeuo-card__nav {
opacity: 1;
pointer-events: auto;
}
.skeuo-card__nav-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.3em;
height: 3.3em;
padding: 0.36em;
border-radius: 3.3em;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
}
.skeuo-card__nav-button:hover:not([disabled]) {
background: rgba(0, 0, 0, 0.75);
}
.skeuo-card__nav-button:disabled {
opacity: 0.4;
cursor: default;
}
.skeuo-card__nav-button:focus-visible {
outline: 2px solid var(--accent, #2684ff);
outline-offset: 2px;
}
.skeuo-card__nav-button svg {
width: 100%;
height: 100%;
}
.skeuo-item__tags .skeuo-tag.is-tear-pending { .skeuo-item__tags .skeuo-tag.is-tear-pending {
opacity: 0.4; opacity: 0.4;
} }
@@ -210,6 +263,7 @@ body.skeuo-cursor-remove * {
justify-content: center; justify-content: center;
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24); box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24);
overflow: hidden; overflow: hidden;
--nav-scale: 1;
} }
.skeuo-item__card--empty { .skeuo-item__card--empty {
+115 -33
View File
@@ -6,7 +6,9 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { resolveDocumentAssetUrl } from './asset_manager'; import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import { useAssetNavigator } from './hooks/useAssetNavigator';
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
import { getReadableTextColor } from './utils/colors'; import { getReadableTextColor } from './utils/colors';
import './skeuomorphic_ws.css'; import './skeuomorphic_ws.css';
@@ -43,6 +45,107 @@ const resolveTagKey = (tag) => {
return key != null ? String(key) : null; return key != null ? String(key) : null;
}; };
const SkeuoPreviewCard = ({
doc,
title,
ensureAssetUrl,
getDocumentAsset,
navScale = 1,
prefetch = 3,
}) => {
const navigator = useAssetNavigator({
document: doc,
assetType: 'preview',
ensureAssetUrl,
getAsset: getDocumentAsset,
prefetch,
});
const { currentUrl, cardinality, canGoPrev, canGoNext } = navigator;
const hasPreview = Boolean(currentUrl);
const cardClasses = ['skeuo-item__card'];
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
const navStyle = useMemo(() => ({ '--nav-scale': navScale }), [navScale]);
return (
<div className={cardClasses.join(' ')}>
{hasPreview ? (
<img src={currentUrl} alt={title} />
) : (
<div className="skeuo-item__empty">
<div className="skeuo-item__placeholder">DOC</div>
<div className="skeuo-item__title" title={title}>
{title}
</div>
</div>
)}
{showNav ? (
<div className="skeuo-card__nav" style={navStyle}>
<button
type="button"
className="skeuo-card__nav-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
navigator.goPrev();
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
disabled={!canGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="skeuo-card__nav-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
navigator.goNext();
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
disabled={!canGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div>
);
};
const generateInitialLayout = ( const generateInitialLayout = (
entries, entries,
{ {
@@ -502,10 +605,10 @@ const SkeuomorphicWorkspace = ({
(doc) => { (doc) => {
if (!doc) return null; if (!doc) return null;
const asset = resolvePreviewAsset(doc); const asset = resolvePreviewAsset(doc);
const primaryObject = asset?.objects?.[0] || null; const view = createAssetView(asset);
const primaryMetadata = primaryObject?.metadata || asset?.metadata || {}; const metadata = view.getPrimaryMetadata() || {};
const width = primaryMetadata?.width; const width = metadata?.width;
const height = primaryMetadata?.height; const height = metadata?.height;
if (typeof width === 'number' && typeof height === 'number') { if (typeof width === 'number' && typeof height === 'number') {
return { width, height }; return { width, height };
} }
@@ -514,17 +617,6 @@ const SkeuomorphicWorkspace = ({
[resolvePreviewAsset], [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;
@@ -1491,9 +1583,6 @@ const SkeuomorphicWorkspace = ({
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);
const imageUrl = previewUrl;
const hasPreview = Boolean(imageUrl);
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;
const tags = Array.isArray(doc.tags) ? doc.tags : []; const tags = Array.isArray(doc.tags) ? doc.tags : [];
@@ -1510,8 +1599,6 @@ const SkeuomorphicWorkspace = ({
if (dropActive) itemClasses.push('is-tag-target'); if (dropActive) itemClasses.push('is-tag-target');
if (dropPending) itemClasses.push('is-tag-pending'); if (dropPending) itemClasses.push('is-tag-pending');
if (!matchesFilter) itemClasses.push('is-filtered-out'); if (!matchesFilter) itemClasses.push('is-filtered-out');
const cardClasses = ['skeuo-item__card'];
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
const docTagTokens = docTagKeys.join(' '); const docTagTokens = docTagKeys.join(' ');
return ( return (
<div <div
@@ -1547,18 +1634,13 @@ const SkeuomorphicWorkspace = ({
}} }}
> >
<div className="skeuo-item__body" style={bodyStyle}> <div className="skeuo-item__body" style={bodyStyle}>
<div className={cardClasses.join(' ')}> <SkeuoPreviewCard
{hasPreview ? ( doc={doc}
<img src={imageUrl} alt={title} /> title={title}
) : ( ensureAssetUrl={ensureAssetUrl}
<div className="skeuo-item__empty"> getDocumentAsset={getDocumentAsset}
<div className="skeuo-item__placeholder">DOC</div> navScale={inverseTagScale}
<div className="skeuo-item__title" title={title}> />
{title}
</div>
</div>
)}
</div>
{tags.length > 0 && ( {tags.length > 0 && (
<div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}> <div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}>
{tags.map((tag) => { {tags.map((tag) => {
+73 -4
View File
@@ -393,7 +393,7 @@ button.icon-button.ghost:hover:not([disabled]) {
.documents-main { .documents-main {
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: 20rem minmax(0, 1fr) 20rem; grid-template-columns: 20rem minmax(0, 1fr) 30rem;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
@@ -1590,6 +1590,7 @@ button.icon-button.ghost:hover:not([disabled]) {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: visible; overflow: visible;
position: relative;
} }
.thumbnail-preview { .thumbnail-preview {
@@ -1672,7 +1673,7 @@ button.icon-button.ghost:hover:not([disabled]) {
.preview-stack--stacked { .preview-stack--stacked {
width: 100%; width: 100%;
min-height: 320px; min-height: 420px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1728,11 +1729,75 @@ button.icon-button.ghost:hover:not([disabled]) {
box-shadow: box-shadow:
inset 0 0 0 999px var(--accent-elevated), inset 0 0 0 999px var(--accent-elevated),
0 6px 18px var(--accent-elevated-strong); 0 6px 18px var(--accent-elevated-strong);
filter: saturate(118%) brightness(1.05);
} }
.preview-pane--stack { .preview-pane--stack {
min-height: 260px; min-height: 460px;
position: relative;
--preview-nav-scale: 1;
}
.preview-pane__nav-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.2em;
height: 2.2em;
padding: 0.45em;
border-radius: 999px;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
pointer-events: auto;
}
.preview-pane__nav-button:hover:not([disabled]) {
background: rgba(0, 0, 0, 0.75);
}
.preview-pane__nav-button:disabled {
opacity: 0.4;
cursor: default;
}
.preview-pane__nav-button:focus-visible {
outline: 2px solid var(--accent, #2684ff);
outline-offset: 2px;
}
.preview-pane__nav {
margin-top: 0.5rem;
display: flex;
justify-content: center;
gap: 0.5rem;
}
.preview-pane__nav--overlay {
position: absolute;
bottom: 0.75rem;
left: 50%;
transform: translateX(-50%) scale(var(--preview-nav-scale, 1));
margin-top: 0;
pointer-events: none;
z-index: 20;
opacity: 0;
transition: opacity 0.2s ease;
}
.preview-pane__nav--overlay .preview-pane__nav-button {
pointer-events: auto;
transform: scale(calc(1 / var(--preview-nav-scale, 1)));
}
.preview-pane--stack:hover .preview-pane__nav--overlay,
.skeuo-item__card:hover .preview-pane__nav--overlay {
opacity: 1;
}
.skeuo-card__nav {
bottom: 0.5rem;
} }
.bulk-tags { .bulk-tags {
@@ -2023,3 +2088,7 @@ form.inline {
width: 0.9rem; width: 0.9rem;
height: 0.9rem; height: 0.9rem;
} }
.preview-pane__nav-button svg {
width: 100%;
height: 100%;
}
+20
View File
@@ -7,6 +7,8 @@ import {
IconTrash, IconTrash,
IconLayoutList, IconLayoutList,
IconLayoutGrid, IconLayoutGrid,
IconArrowLeft,
IconArrowRight,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import FolderSvg from '../assets/folder.svg'; import FolderSvg from '../assets/folder.svg';
@@ -100,6 +102,24 @@ export const ViewGridIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
/> />
); );
export const ArrowLeftIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconArrowLeft
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ArrowRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconArrowRight
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export default { export default {
ChevronIcon, ChevronIcon,
TrashIcon, TrashIcon,