From dd7dae5f9d3782b8bc472c5f5ff0701c0556ee66 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 21 Nov 2025 15:41:55 +0100 Subject: [PATCH 01/17] folder in summary --- frontend/src/app/useWorkspaceSurface.tsx | 85 ++++++++++++------- frontend/src/detail/useDetailWorkspace.ts | 20 +---- .../src/documents/DocumentSummarySection.tsx | 50 +++++++++++ frontend/src/documents/documentSummary.ts | 9 +- frontend/src/folders/FolderManagerContext.tsx | 59 +++++++++++++ .../hooks/documents/useDocumentsWorkspace.ts | 4 - frontend/src/preview/DocumentViewerPanel.tsx | 60 ++++++------- 7 files changed, 201 insertions(+), 86 deletions(-) create mode 100644 frontend/src/folders/FolderManagerContext.tsx diff --git a/frontend/src/app/useWorkspaceSurface.tsx b/frontend/src/app/useWorkspaceSurface.tsx index 99ed49a..6b94b9c 100644 --- a/frontend/src/app/useWorkspaceSurface.tsx +++ b/frontend/src/app/useWorkspaceSurface.tsx @@ -4,6 +4,7 @@ import { SidebarExpandIcon } from '../ui/icons'; import DocumentsPanel from '../documents/panel/DocumentsPanel'; import DocumentViewerPanel from '../preview/DocumentViewerPanel'; import { usePanelManager } from './PanelManagerContext'; +import { FolderManagerProvider } from '../folders/FolderManagerContext'; type Identifier = string | number; @@ -91,8 +92,15 @@ export const useWorkspaceSurface = ({ const detail = detailPanelOpen && detailPanelProps ? (() => { - const { onClose, onOpenPreview, tags: tagOptions, ...restDetailProps } = detailPanelProps; - return ( + const { + onClose, + onOpenPreview, + tags: tagOptions, + folderNodes, + ensureFolderData, + ...restDetailProps + } = detailPanelProps; + const viewer = ( ); + if (folderNodes && ensureFolderData) { + return ( + + {viewer} + + ); + } + return ( + <>{viewer} + ); })() : null; @@ -140,35 +158,44 @@ export const useWorkspaceSurface = ({ onUpdateTitle, onUpdateIssued, resolveFolderPath, + folderNodes, + ensureFolderData, } = detailExtras; - return { - content: ( - - ), - detail: null, - }; + const viewer = ( + + ); + + const content = folderNodes && ensureFolderData + ? ( + + {viewer} + + ) + : viewer; + + return { content, detail: null }; }, [ showPreviewWorkspace, previewWorkspaceDocument, diff --git a/frontend/src/detail/useDetailWorkspace.ts b/frontend/src/detail/useDetailWorkspace.ts index 115684c..91a050e 100644 --- a/frontend/src/detail/useDetailWorkspace.ts +++ b/frontend/src/detail/useDetailWorkspace.ts @@ -25,11 +25,6 @@ interface FolderNode { parentId?: Identifier | 'root'; } -type DocumentLink = { - url?: string; - contentType?: string | null; -} | null; - interface UseDetailWorkspaceArgs { documents: DocumentLike[]; selectionOrder: string[]; @@ -39,7 +34,6 @@ interface UseDetailWorkspaceArgs { ensureFolderData: (folderId: Identifier | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise; detailPanelControlRef: MutableRefObject<{ open?: (args?: { documentIds?: Identifier[] }) => void; close?: () => void } | null>; detailFolderFetchRef: MutableRefObject>; - documentLinks: Map; previewDocumentId?: Identifier | null; activePreviewId?: Identifier | null; openDocumentPreview?: (args: { documentIds: Identifier[] }) => void; @@ -67,7 +61,6 @@ interface UseDetailWorkspaceResult { inspectDocument: (docId: Identifier | null) => void; previewActive: boolean; previewWorkspaceDocument: DocumentLike | null; - documentLink: DocumentLink; resolveThumbnailUrlForDoc: (doc: DocumentLike | null) => string | null; resolveFolderPath: (folderId?: Identifier | 'root') => Array<{ id: Identifier | 'root'; name: string }>; } @@ -81,7 +74,6 @@ const useDetailWorkspace = ({ ensureFolderData, detailPanelControlRef, detailFolderFetchRef, - documentLinks, previewDocumentId, activePreviewId, openDocumentPreview, @@ -239,11 +231,6 @@ const useDetailWorkspace = ({ [folderNodes], ); - const documentLink = useMemo( - () => (detailPanelDocument ? documentLinks.get(detailPanelDocument.id) || null : null), - [detailPanelDocument, documentLinks], - ); - const previewWorkspaceDocument = useMemo(() => { if (!previewDocumentId) { return null; @@ -285,7 +272,6 @@ const useDetailWorkspace = ({ tagLookupById, onTagAdd: handleDocumentTagAdd, onTagRemove: handleTagRemove, - documentLink, onOpenPreview: openDocumentPreview, activePreviewId, onUpdateTitle: handleDocumentTitleUpdate, @@ -299,6 +285,8 @@ const useDetailWorkspace = ({ onFolderNavigate: selectFolder, onClose: handleDetailPanelClose, resolveFolderPath, + folderNodes, + ensureFolderData, }), [ activePreviewId, @@ -313,11 +301,12 @@ const useDetailWorkspace = ({ handleDocumentIssuedUpdate, handleDocumentTitleUpdate, handleTagRemove, + folderNodes, + ensureFolderData, openDocumentPreview, resolveApiPath, resolveFolderPath, selectFolder, - documentLink, tags, tagLookupById, ], @@ -332,7 +321,6 @@ const useDetailWorkspace = ({ inspectDocument, previewActive, previewWorkspaceDocument, - documentLink, resolveThumbnailUrlForDoc, resolveFolderPath, }; diff --git a/frontend/src/documents/DocumentSummarySection.tsx b/frontend/src/documents/DocumentSummarySection.tsx index e73bd75..258be49 100644 --- a/frontend/src/documents/DocumentSummarySection.tsx +++ b/frontend/src/documents/DocumentSummarySection.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; +import { Link } from 'react-router-dom'; import { EditIcon, IconX, CheckIcon, PlusIcon } from '../ui/icons'; import SelectionAssignmentMenu, { SelectionAssignmentMenuItem, @@ -12,6 +13,7 @@ import { } from '../utils/date'; import { describeDocumentSummary, type DocumentSummaryRow } from './documentSummary'; import { isPlainObject } from '../utils/typeGuards'; +import { useFolderManager } from '../folders/FolderManagerContext'; type Identifier = string | number; @@ -31,6 +33,7 @@ interface DocumentLike { id?: Identifier; title?: string; issued_at?: string | null; + folder_id?: string | null; current_version?: { version_number?: number } | null; tags?: TagEntry[]; correspondents?: CorrespondentEntry[]; @@ -71,6 +74,7 @@ export interface DocumentSummarySectionProps { onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void; onUpdateTitle?: (docId: Identifier | undefined, title: string) => Promise | boolean; onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise | boolean; + onFolderNavigate?: (folderId: string | null) => void; layout?: 'default' | 'compact'; } @@ -456,8 +460,10 @@ const DocumentSummarySection: React.FC = ({ onCorrespondentRemove, onUpdateTitle, onUpdateIssued, + onFolderNavigate, layout = 'default', }) => { + const folderManager = useFolderManager(); const isCompactLayout = layout === 'compact'; const summaryRows = useMemo(() => describeDocumentSummary(document), [document]); const issuedDateLabel = useMemo( @@ -499,6 +505,39 @@ const DocumentSummarySection: React.FC = ({ return rows; }, [document?.current_version?.version_number]); + const resolvedFolderId = document?.folder_id ?? null; + + const [folderName, setFolderName] = useState(() => folderManager.getNameSync(resolvedFolderId)); + + useEffect(() => { + let active = true; + const cached = folderManager.getNameSync(resolvedFolderId); + setFolderName(cached); + if (!cached && resolvedFolderId != null) { + folderManager.resolveName(resolvedFolderId).then((name) => { + if (active) { + setFolderName(name); + } + }).catch(() => {}); + } + return () => { + active = false; + }; + }, [resolvedFolderId, folderManager]); + + const folderHref = resolvedFolderId == null ? '/documents' : `/documents/folder/${resolvedFolderId}`; + + const handleFolderClick = useCallback( + (event: React.MouseEvent) => { + if (!onFolderNavigate) { + return; + } + event.preventDefault(); + onFolderNavigate(resolvedFolderId); + }, + [onFolderNavigate, resolvedFolderId], + ); + const [titleDraft, setTitleDraft] = useState(''); const [titleSaving, setTitleSaving] = useState(false); const [titleError, setTitleError] = useState(null); @@ -741,11 +780,22 @@ const DocumentSummarySection: React.FC = ({ /> ); + const folderValueContent = ( + + {folderName} + + ); + const summaryRowOverrides = { title: { valueContent: titleMetaDisplay, error: titleError }, issued: { valueContent: issuedDisplay, error: issuedError }, tags: { valueContent: tagsValueContent }, correspondents: { valueContent: correspondentsValueContent }, + folder: { valueContent: folderValueContent }, } as Record; const baseRows: MetaItem[] = [...summaryRows, ...extraSummaryRows].map((row) => { diff --git a/frontend/src/documents/documentSummary.ts b/frontend/src/documents/documentSummary.ts index 9e6467f..eac86d8 100644 --- a/frontend/src/documents/documentSummary.ts +++ b/frontend/src/documents/documentSummary.ts @@ -1,5 +1,6 @@ import { formatFileSize } from '../utils/format'; import { formatDateTime as defaultFormatDateTime } from '../utils/date'; +import { DEFAULT_FOLDER_NAME } from '../app/appLayoutUtils'; interface DocumentPageMetadata { page_count?: number | string | null; @@ -24,11 +25,13 @@ export interface SummaryDocument { original_name?: string | null; filename?: string | null; content_type?: string | null; + folder_id?: string | null; + folder_name?: string; current_version?: DocumentVersion | null; created_at?: string | null; updated_at?: string | null; issued_at?: string | null; - folder_path?: string | null; + folder_path?: string; tags?: TagEntry[] | null; correspondents?: CorrespondentEntry[] | null; } @@ -37,7 +40,7 @@ interface DescribeSummaryOptions { formatDateTime?: typeof defaultFormatDateTime; } -export type DocumentSummaryRowType = 'text' | 'editable-title' | 'editable-issued' | 'tags' | 'correspondents'; +export type DocumentSummaryRowType = 'text' | 'editable-title' | 'editable-issued' | 'tags' | 'correspondents' | 'folder'; export interface DocumentSummaryRow { key: string; @@ -86,6 +89,7 @@ export const describeDocumentSummary = (document?: SummaryDocument | null, optio const metadata = doc.current_version?.metadata || null; const pageCount = coercePageCount(metadata); const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—'; + const folderLabel = doc.folder_id == null ? DEFAULT_FOLDER_NAME : `Folder ${doc.folder_id}`; const tags = sanitizeArray(doc.tags); const correspondents = sanitizeArray(doc.correspondents); const tagLabels = tags.map((tag) => tag.label).filter(Boolean) as string[]; @@ -99,6 +103,7 @@ export const describeDocumentSummary = (document?: SummaryDocument | null, optio { key: 'issued', label: 'Issued', value: formatDateLabel(doc.issued_at), kind: 'editable-issued' }, { key: 'created', label: 'Created at', value: formatDateLabel(doc.created_at) }, { key: 'updated', label: 'Updated at', value: formatDateLabel(doc.updated_at) }, + { key: 'folder', label: 'Folder', value: folderLabel, kind: 'folder' }, { key: 'size', label: 'Size', value: sizeLabel }, { key: 'content-type', label: 'Content type', value: doc.content_type || 'Unknown' }, { key: 'pages', label: 'Pages', value: pageCountLabel }, diff --git a/frontend/src/folders/FolderManagerContext.tsx b/frontend/src/folders/FolderManagerContext.tsx new file mode 100644 index 0000000..9fbb1a6 --- /dev/null +++ b/frontend/src/folders/FolderManagerContext.tsx @@ -0,0 +1,59 @@ +import React, { createContext, useContext, useMemo, type ReactNode } from 'react'; +import { DEFAULT_FOLDER_NAME } from '../app/appLayoutUtils'; + +type FolderId = string | null; + +export interface FolderManager { + getNameSync: (folderId: FolderId) => string | null; + resolveName: (folderId: FolderId) => Promise; +} + +const defaultManager: FolderManager = { + getNameSync: (folderId) => (folderId == null ? DEFAULT_FOLDER_NAME : `Folder ${folderId}`), + resolveName: async (folderId) => (folderId == null ? DEFAULT_FOLDER_NAME : `Folder ${folderId}`), +}; + +const FolderManagerContext = createContext(defaultManager); + +interface FolderManagerProviderProps { + folderNodes?: Map; + ensureFolderData?: (folderId: string | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise; + children: ReactNode; +} + +export const FolderManagerProvider: React.FC = ({ + folderNodes, + ensureFolderData, + children, +}) => { + const value = useMemo(() => { + if (!folderNodes || !ensureFolderData) { + return defaultManager; + } + + const getNameSync = (folderId: FolderId) => { + if (folderId == null) return DEFAULT_FOLDER_NAME; + return folderNodes.get(folderId)?.name ?? null; + }; + + const resolveName = async (folderId: FolderId) => { + const cached = getNameSync(folderId); + if (cached) return cached; + if (folderId == null) return DEFAULT_FOLDER_NAME; + await ensureFolderData(folderId, { includeDocuments: false }); + return getNameSync(folderId) ?? `Folder ${folderId}`; + }; + + return { getNameSync, resolveName }; + }, [folderNodes, ensureFolderData]); + + return ( + + {children} + + ); +}; + +export const useFolderManager = (): FolderManager => useContext(FolderManagerContext); + +export default FolderManagerContext; diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.ts b/frontend/src/hooks/documents/useDocumentsWorkspace.ts index 921fcd9..ccc850d 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.ts +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts @@ -1233,7 +1233,6 @@ const useDocumentsWorkspace = ({ inspectDocument, previewActive, previewWorkspaceDocument, - documentLink, resolveFolderPath, } = useDetailWorkspace({ documents: viewDocuments, @@ -1244,7 +1243,6 @@ const useDocumentsWorkspace = ({ ensureFolderData, detailPanelControlRef, detailFolderFetchRef, - documentLinks, previewDocumentId, activePreviewId, openDocumentPreview: openDocumentPreviewForDetail, @@ -1599,7 +1597,6 @@ const useDocumentsWorkspace = ({ revokePasskey, previewActive, previewWorkspaceDocument, - documentLink, previewDocumentId, closeDocumentPreview, handleThumbnailRegeneration, @@ -1650,7 +1647,6 @@ const useDocumentsWorkspace = ({ revokePasskey, previewActive, previewWorkspaceDocument, - documentLink, previewDocumentId, closeDocumentPreview, handleThumbnailRegeneration, diff --git a/frontend/src/preview/DocumentViewerPanel.tsx b/frontend/src/preview/DocumentViewerPanel.tsx index a67623d..65a5ca2 100644 --- a/frontend/src/preview/DocumentViewerPanel.tsx +++ b/frontend/src/preview/DocumentViewerPanel.tsx @@ -34,6 +34,7 @@ interface DocumentLike { title?: string; content_type?: string | null; issued_at?: string | null; + folder_id?: string | null; correspondents?: Array<{ id?: string | number; name?: string }>; current_version?: { version_number?: number; @@ -56,11 +57,6 @@ interface AssetLike { interface DocumentViewerPanelProps extends DocumentSummarySectionProps { document: DocumentLike | null; - documentLink?: { - url?: string; - contentType?: string | null; - filename?: string | null; - } | null; ensureAssetUrl?: (docId: string | number, asset: AssetLike, options?: { force?: boolean }) => Promise; getDocumentAsset?: (doc: DocumentLike | null, type: string) => AssetLike | null; ensurePreviewData?: (docId: string | number, options?: { signal?: AbortSignal }) => Promise; @@ -78,7 +74,6 @@ interface DocumentViewerPanelProps extends DocumentSummarySectionProps { export const createDocumentViewerHeaderActions = ({ document, actionState, - documentLink, onZoom, canZoom = false, }) => { @@ -86,7 +81,7 @@ export const createDocumentViewerHeaderActions = ({ return null; } - const downloadHref = actionState?.downloadHref || documentLink?.url; + const downloadHref = actionState?.downloadHref; if (!downloadHref && !(canZoom && onZoom)) { return null; } @@ -122,7 +117,6 @@ export const createDocumentViewerHeaderActions = ({ const DocumentViewerPanel: React.FC = ({ document, - documentLink, tagLookupById, tagOptions, onTagAdd, @@ -168,6 +162,16 @@ const DocumentViewerPanel: React.FC = ({ return Boolean(getDocumentAsset(document, 'ocr-text')); }, [document, getDocumentAsset]); + const navigateToFolder = useCallback( + (folderId) => { + const target = folderId == null + ? '/documents' + : `/documents/folder/${folderId}`; + navigate(target); + }, + [navigate], + ); + const summaryProps = useMemo( () => ({ tagLookupById, @@ -180,6 +184,7 @@ const DocumentViewerPanel: React.FC = ({ onCorrespondentRemove, onUpdateTitle, onUpdateIssued, + onFolderNavigate: navigateToFolder, }), [ tagLookupById, @@ -192,6 +197,7 @@ const DocumentViewerPanel: React.FC = ({ onCorrespondentRemove, onUpdateTitle, onUpdateIssued, + navigateToFolder, ], ); @@ -251,7 +257,7 @@ const DocumentViewerPanel: React.FC = ({ const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false); - const fallbackDocumentLink = useMemo(() => { + const resolvedDocumentLink = useMemo(() => { if (!document) { return null; } @@ -272,14 +278,12 @@ const DocumentViewerPanel: React.FC = ({ }; }, [document, resolveApiPath]); - const effectiveDocumentLink = documentLink?.url ? documentLink : fallbackDocumentLink; - const handleZoomOpen = useCallback(() => { - if (!effectiveDocumentLink?.url) { + if (!resolvedDocumentLink?.url) { return; } setZoomOverlayOpen(true); - }, [effectiveDocumentLink?.url]); + }, [resolvedDocumentLink?.url]); const handleZoomClose = useCallback(() => { setZoomOverlayOpen(false); @@ -287,7 +291,7 @@ const DocumentViewerPanel: React.FC = ({ useEffect(() => { setZoomOverlayOpen(false); - }, [effectiveDocumentLink?.url, document?.id]); + }, [resolvedDocumentLink?.url, document?.id]); const panelRef = useRef(null); const isStackedLayout = useViewerLayoutMode(panelRef, document?.id); @@ -344,19 +348,6 @@ const DocumentViewerPanel: React.FC = ({ ]; }, [document, resolveFolderPath]); - const handleBreadcrumbNavigate = useCallback( - (crumb) => { - if (!crumb?.id) { - return; - } - const target = crumb.id === 'root' - ? '/documents' - : `/documents/folder/${crumb.id}`; - navigate(target); - }, - [navigate], - ); - const breadcrumbTrailEntries = useMemo(() => { if (!breadcrumbs.length) { return []; @@ -365,28 +356,27 @@ const DocumentViewerPanel: React.FC = ({ return breadcrumbs.map((crumb, index) => ({ id: crumb.id, label: crumb.name, - onClick: index < lastIndex ? () => handleBreadcrumbNavigate(crumb) : null, + onClick: index < lastIndex ? () => navigateToFolder(crumb.id) : null, })); - }, [breadcrumbs, handleBreadcrumbNavigate]); + }, [breadcrumbs, navigateToFolder]); const zoomDisplay = useMemo(() => { - if (!effectiveDocumentLink?.url || !document) { + if (!resolvedDocumentLink?.url || !document) { return null; } const docContentType = document.content_type; const versionContentType = document.current_version?.version?.content_type; - const normalizedContentType = effectiveDocumentLink.contentType || docContentType || versionContentType || null; + const normalizedContentType = resolvedDocumentLink.contentType || docContentType || versionContentType || null; return { - url: effectiveDocumentLink.url, + url: resolvedDocumentLink.url, alt: document.title, contentType: normalizedContentType || undefined, }; - }, [effectiveDocumentLink?.url, effectiveDocumentLink?.contentType, document]); + }, [resolvedDocumentLink?.url, resolvedDocumentLink?.contentType, document]); const headerActions = createDocumentViewerHeaderActions({ document, actionState, - documentLink: effectiveDocumentLink, onZoom: zoomDisplay ? handleZoomOpen : null, canZoom: Boolean(zoomDisplay), }); @@ -486,7 +476,7 @@ const DocumentViewerPanel: React.FC = ({
Date: Sat, 22 Nov 2025 00:28:11 +0100 Subject: [PATCH 02/17] download url --- frontend/src/app/useDocumentPreview.ts | 18 ++++---- frontend/src/asset_manager.ts | 44 ++++++++----------- frontend/src/documents/documentActions.ts | 8 ++-- .../hooks/documents/useDocumentsWorkspace.ts | 1 - frontend/src/preview/DocumentViewerPanel.tsx | 9 ++-- frontend/src/utils/ocr.ts | 6 +-- 6 files changed, 35 insertions(+), 51 deletions(-) diff --git a/frontend/src/app/useDocumentPreview.ts b/frontend/src/app/useDocumentPreview.ts index aa09d21..848b64d 100644 --- a/frontend/src/app/useDocumentPreview.ts +++ b/frontend/src/app/useDocumentPreview.ts @@ -40,7 +40,6 @@ interface UseDocumentPreviewArgs { }; selectedFolder?: FolderId | null; api: ApiClient; - resolveApiPath?: (path: string) => string; notifyApiError: (error: unknown, message: string) => void; navigate: NavigateHandler; locationPathname: string; @@ -67,7 +66,6 @@ const useDocumentPreview = ({ documentsManager, selectedFolder, api, - resolveApiPath, notifyApiError, navigate, locationPathname, @@ -109,7 +107,7 @@ const useDocumentPreview = ({ async (documentId: DocumentId, { force = false }: { force?: boolean } = {}): Promise => { if (!documentId) return null; - const existing = documentLinks.get(documentId) || null; + const existing = documentLinks.get(documentId); const now = Date.now(); const expiresAt = Number.isFinite(existing?.expiresAt) ? Number(existing?.expiresAt) : null; if (!force && existing && (!expiresAt || expiresAt > now)) { @@ -123,17 +121,17 @@ const useDocumentPreview = ({ const request: Promise = (async () => { try { const docResponse = await api.get<{ document?: Record }>(`/documents/${documentId}`); - const downloadPath = docResponse.data?.document?.current_version?.download_path; - if (!downloadPath || !resolveApiPath) { - throw new Error('Document missing download path'); + const download = docResponse.data?.document?.current_version?.download || null; + const downloadUrl = download?.url; + if (!downloadUrl) { + throw new Error('Document missing download url'); } - const href = resolveApiPath(downloadPath); const entry: DocumentLink = { - url: href, + url: downloadUrl, contentType: docResponse.data?.document?.current_version?.version?.content_type || null, filename: docResponse.data?.document?.filename, - expiresAt: Date.now() + 5 * 60 * 1000, + expiresAt: download?.expires_at, }; setDocumentLinks((prev) => { const next = new Map(prev); @@ -152,7 +150,7 @@ const useDocumentPreview = ({ previewInflightRef.current.set(documentId, request); return request; }, - [documentLinks, api, resolveApiPath, notifyApiError], + [documentLinks, api, notifyApiError], ); const ensurePreviewData = useCallback( diff --git a/frontend/src/asset_manager.ts b/frontend/src/asset_manager.ts index deb6161..ec4fded 100644 --- a/frontend/src/asset_manager.ts +++ b/frontend/src/asset_manager.ts @@ -8,7 +8,7 @@ export interface AssetObject { ordinal?: number; url?: string | null; metadata?: Record | null; - expires_at?: number | null; + expires_at?: number; [key: string]: unknown; } @@ -16,10 +16,8 @@ export interface AssetLike { id?: Identifier; asset_type?: string; cardinality?: number | null; - url?: string | null; - expires_at?: number | null; + download?: { url: string; expires_at: number } | null; metadata?: Record | null; - expiresAt?: number | null; assets?: Record | AssetLike[] | null; objects?: AssetObject[] | null; [key: string]: unknown; @@ -37,19 +35,11 @@ export interface DocumentLike { [key: string]: unknown; } -export const resolveAssetExpiresAt = ( - asset?: { expiresAt?: number | null; expires_at?: number | null } | null, -): number | null => { - const camel = Number(asset?.expiresAt); - if (Number.isFinite(camel)) { - return camel; - } - const snake = Number(asset?.expires_at); - if (Number.isFinite(snake)) { - return snake; - } - return null; -}; +export const resolveAssetExpiresAt = (asset?: { download?: { expires_at: number } | null } | null): number | null => + asset?.download?.expires_at ?? null; + +export const resolveAssetUrl = (asset?: { download?: { url: string } | null } | null): string | null => + asset?.download?.url ?? null; export type EnsureAssetUrl = ( documentId: Identifier, @@ -78,7 +68,7 @@ export const getAssetFromVersion = (currentVersion: Nullable { @@ -140,10 +130,11 @@ export class AssetView { } if (ordinal === 1 && this.asset) { - if (this.asset.url || this.asset.metadata) { + const primaryUrl = resolveAssetUrl(this.asset); + if (primaryUrl || this.asset.metadata) { return { ordinal: 1, - url: this.asset.url || null, + url: primaryUrl || null, metadata: this.asset.metadata || null, expires_at: resolveAssetExpiresAt(this.asset), }; @@ -242,7 +233,7 @@ class AssetManager { { force = false }: { force?: boolean } = {}, ): Promise> { if (!documentId || !asset?.id) { - return Promise.resolve(asset ?? null); + return Promise.resolve(asset); } const baseAsset = this.assetCache.get(asset.id) || asset; @@ -258,7 +249,8 @@ class AssetManager { return true; } } - if (baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) { + const assetUrl = resolveAssetUrl(baseAsset); + if (assetUrl && (!assetExpiresAt || assetExpiresAt > now)) { return true; } return false; @@ -288,13 +280,13 @@ class AssetManager { .then(({ data }) => { const cachedEntry = this.assetCache.get(asset.id) || baseAsset; const combined = { ...cachedEntry, ...asset, ...data }; - const expiresAt = + const expires_at = resolveAssetExpiresAt(data) - ?? resolveAssetExpiresAt(combined) - ?? Date.now() + this.assetPresignTtlMs; + ?? resolveAssetExpiresAt(combined); const entry = { ...combined, - expiresAt, + url: resolveAssetUrl(combined), + expires_at, }; this.rememberAsset(entry); diff --git a/frontend/src/documents/documentActions.ts b/frontend/src/documents/documentActions.ts index 099639c..c1875c2 100644 --- a/frontend/src/documents/documentActions.ts +++ b/frontend/src/documents/documentActions.ts @@ -13,14 +13,14 @@ export type DocumentLike = OcrDocumentLike; const asyncFalse = async () => false; const resolveDocumentDownloadHref = (document?: DocumentLike | null, resolveApiPath?: ResolveApiPath | null): string | null => { - if (!document || !resolveApiPath) { + if (!document) { return null; } - const downloadPath = (document.current_version as { download_path?: string | null } | null)?.download_path; - if (!downloadPath) { + const downloadUrl = (document.current_version as { download?: { url: string } | null } | null)?.download?.url; + if (!downloadUrl) { return null; } - return resolveApiPath(downloadPath); + return resolveApiPath ? resolveApiPath(downloadUrl) : downloadUrl; }; const hasDocumentOcrAsset = (document?: DocumentLike | null, getDocumentAsset?: GetDocumentAsset | null): boolean => { diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.ts b/frontend/src/hooks/documents/useDocumentsWorkspace.ts index ccc850d..1d2f1ac 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.ts +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts @@ -450,7 +450,6 @@ const useDocumentsWorkspace = ({ documentsManager, selectedFolder, api, - resolveApiPath, notifyApiError, navigate, locationPathname: location.pathname, diff --git a/frontend/src/preview/DocumentViewerPanel.tsx b/frontend/src/preview/DocumentViewerPanel.tsx index 65a5ca2..6ee8cf8 100644 --- a/frontend/src/preview/DocumentViewerPanel.tsx +++ b/frontend/src/preview/DocumentViewerPanel.tsx @@ -38,7 +38,9 @@ interface DocumentLike { correspondents?: Array<{ id?: string | number; name?: string }>; current_version?: { version_number?: number; + download?: { url?: string | null; expires_at?: number } | null; version?: { content_type?: string | null } | null; + filename?: string | null; } | null; documentLink?: { url: string; @@ -261,11 +263,8 @@ const DocumentViewerPanel: React.FC = ({ if (!document) { return null; } - const downloadPath = document.current_version?.download_path; - if (!downloadPath) { - return null; - } - const href = resolveApiPath ? resolveApiPath(downloadPath) : downloadPath; + const downloadUrl = document.current_version?.download?.url; + const href = resolveApiPath ? resolveApiPath(downloadUrl) : downloadUrl; if (!href) { return null; } diff --git a/frontend/src/utils/ocr.ts b/frontend/src/utils/ocr.ts index 9d44b06..0c6e4d6 100644 --- a/frontend/src/utils/ocr.ts +++ b/frontend/src/utils/ocr.ts @@ -6,12 +6,8 @@ import type { GetAsset as AssetManagerGetAsset, } from '../asset_manager'; -interface DocumentVersion extends DocumentVersionLike { - download_path?: string | null; -} - export interface DocumentLike extends AssetManagerDocumentLike { - current_version?: DocumentVersion | null; + current_version?: DocumentVersionLike | null; } export type AssetLike = AssetManagerAssetLike; From ab3faad6af112856fea37d5de0934bbaf9f81864 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sat, 22 Nov 2025 00:32:42 +0100 Subject: [PATCH 03/17] fix --- frontend/src/styles/modals/status-drop.css | 6 ++---- frontend/src/styles/settings/settings.css | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/src/styles/modals/status-drop.css b/frontend/src/styles/modals/status-drop.css index 483213d..42e4686 100644 --- a/frontend/src/styles/modals/status-drop.css +++ b/frontend/src/styles/modals/status-drop.css @@ -67,11 +67,9 @@ .modal--panel { width: 80vw; - max-width: 960px; - max-height: 90vh; + height: 90vh; padding: 0; - border-radius: 12px; + border-radius: 1rem; overflow: hidden; gap: 0; } - diff --git a/frontend/src/styles/settings/settings.css b/frontend/src/styles/settings/settings.css index 1714e2c..0f1ca26 100644 --- a/frontend/src/styles/settings/settings.css +++ b/frontend/src/styles/settings/settings.css @@ -4,6 +4,7 @@ gap: 1.5rem; height: 90vh; padding: 1.5rem; + overflow: hidden; } .settings-modal__sidebar { @@ -334,4 +335,3 @@ fieldset.settings-form__field legend { .settings-notice__actions button { flex: 0 0 auto; } - From 7bee56b960342852eab2a09584698e6aad0b67bb Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sat, 22 Nov 2025 01:17:25 +0100 Subject: [PATCH 04/17] fix --- frontend/src/app/appLayoutUtils.ts | 10 ++++----- .../src/desktop/hooks/usePreviewMetadata.ts | 10 +++------ .../src/documents/DocumentThumbnailImage.tsx | 22 +++++++++---------- frontend/src/hooks/useAssetNavigator.ts | 18 +++++++-------- frontend/src/utils/ocr.ts | 9 ++++---- 5 files changed, 31 insertions(+), 38 deletions(-) diff --git a/frontend/src/app/appLayoutUtils.ts b/frontend/src/app/appLayoutUtils.ts index 784b107..eebdea1 100644 --- a/frontend/src/app/appLayoutUtils.ts +++ b/frontend/src/app/appLayoutUtils.ts @@ -1,4 +1,4 @@ -import { createAssetView, resolveAssetExpiresAt } from '../asset_manager'; +import { resolveAssetExpiresAt, resolveAssetUrl } from '../asset_manager'; export const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early export const DEFAULT_FOLDER_NAME = 'Documents'; @@ -41,15 +41,13 @@ export const hasFiles = (event) => const isAssetEquivalent = (lhs, rhs) => { if (!lhs || !rhs) return false; - const lhsView = createAssetView(lhs); - const rhsView = createAssetView(rhs); - const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata; - const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata; + const lhsPrimaryMetadata = lhs?.metadata; + const rhsPrimaryMetadata = rhs?.metadata; const lhsExpiresAt = resolveAssetExpiresAt(lhs); const rhsExpiresAt = resolveAssetExpiresAt(rhs); return ( lhs.id === rhs.id - && lhs.url === rhs.url + && resolveAssetUrl(lhs) === resolveAssetUrl(rhs) && lhsExpiresAt === rhsExpiresAt && lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width && lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height diff --git a/frontend/src/desktop/hooks/usePreviewMetadata.ts b/frontend/src/desktop/hooks/usePreviewMetadata.ts index 3133fff..cbb9023 100644 --- a/frontend/src/desktop/hooks/usePreviewMetadata.ts +++ b/frontend/src/desktop/hooks/usePreviewMetadata.ts @@ -1,6 +1,4 @@ import { useEffect, useState } from 'react'; -import { createAssetView } from '../../asset_manager'; - interface DocumentLike { id?: string | number; current_version?: unknown; @@ -47,8 +45,7 @@ const usePreviewMetadata = ( const resolveAsset = (type: string) => getDocumentAsset?.(doc, type) ?? null; let asset = resolveAsset('preview') || resolveAsset('thumbnail'); - let view = createAssetView(asset); - let metadata = view.getPrimaryMetadata(); + let metadata = (asset?.metadata as { width?: number; height?: number } | null) || null; const hasDimensions = (meta: { width?: number | string; height?: number | string } | null) => Number.isFinite(Number(meta?.width)) && @@ -58,11 +55,10 @@ const usePreviewMetadata = ( if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { try { - const ensured = await ensureAssetUrl(doc.id, asset, { force: true }); + const ensured = await ensureAssetUrl(doc.id, asset); if (ensured) { asset = ensured; - view = createAssetView(asset); - metadata = view.getPrimaryMetadata(); + metadata = (asset?.metadata as { width?: number; height?: number } | null) || null; } } catch (error) { console.warn('[desk] ensureDocumentSize metadata fetch failed', error); diff --git a/frontend/src/documents/DocumentThumbnailImage.tsx b/frontend/src/documents/DocumentThumbnailImage.tsx index 1e0679b..70548c9 100644 --- a/frontend/src/documents/DocumentThumbnailImage.tsx +++ b/frontend/src/documents/DocumentThumbnailImage.tsx @@ -3,7 +3,7 @@ import type { CSSProperties, JSX, MutableRefObject } from 'react'; import { getAssetFromVersion, resolveDocumentAssetUrl, - createAssetView, + resolveAssetUrl, } from '../asset_manager'; import type { DocumentLike as AssetManagerDocumentLike, @@ -101,13 +101,13 @@ const DocumentThumbnailImage = ({ () => getAssetFromVersion(document?.current_version, 'thumbnail'), [document?.current_version], ); - const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]); - const primaryMetadata = thumbnailView.getPrimaryMetadata() || {}; - const assetWidth = Number(primaryMetadata?.width); - const assetHeight = Number(primaryMetadata?.height); + const thumbnailMetadata = (thumbnailAsset?.metadata as { width?: number; height?: number } | null) || null; + const assetWidth = thumbnailMetadata?.width; + const assetHeight = thumbnailMetadata?.height; const dimensions = useMemo(() => { - if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) { + const hasDimensions = typeof assetWidth === 'number' && assetWidth > 0 && typeof assetHeight === 'number' && assetHeight > 0; + if (!hasDimensions) { return { width: resolvedMaxSize, height: resolvedMaxSize }; } const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight); @@ -136,8 +136,8 @@ const DocumentThumbnailImage = ({ if (getDocumentAsset) { options.getAsset = getDocumentAsset; } - return resolveDocumentAssetUrl(document, 'thumbnail', options); - }, [document, ensureAssetUrl, getDocumentAsset, isVisible]); + return resolveDocumentAssetUrl(document, 'thumbnail', options) || resolveAssetUrl(thumbnailAsset); + }, [document, ensureAssetUrl, getDocumentAsset, isVisible, thumbnailAsset]); const pageCount = getPageCount(document); const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1; @@ -147,11 +147,11 @@ const DocumentThumbnailImage = ({ } const aspectRatio = useMemo(() => { - if (Number.isFinite(assetWidth) && Number.isFinite(assetHeight) && assetWidth > 0 && assetHeight > 0) { - return assetWidth / assetHeight; + if (dimensions.width > 0 && dimensions.height > 0) { + return dimensions.width / dimensions.height; } return null; - }, [assetWidth, assetHeight]); + }, [dimensions.height, dimensions.width]); useEffect(() => { const node = visibilityRef.current; diff --git a/frontend/src/hooks/useAssetNavigator.ts b/frontend/src/hooks/useAssetNavigator.ts index 02ad293..decf5de 100644 --- a/frontend/src/hooks/useAssetNavigator.ts +++ b/frontend/src/hooks/useAssetNavigator.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { createAssetView } from '../asset_manager'; +import { resolveAssetUrl } from '../asset_manager'; type Identifier = string | number; @@ -33,10 +33,8 @@ type EnsureAssetUrl = ( type GetAsset = (document: DocumentLike, assetType: string) => AssetLike | null; type AssetViewLike = { - getObject: (ordinal?: number) => AssetObject | null; - getObjects: () => AssetObject[]; - getPrimaryUrl: () => string | null; - getPrimaryMetadata: () => Record | null; + url: string | null; + metadata: Record | null; }; interface UseAssetNavigatorOptions { @@ -72,13 +70,15 @@ export const useAssetNavigator = ({ }, [document, assetType, getAsset]); const view = useMemo( - () => createAssetView(asset) as unknown as AssetViewLike, + () => ({ + url: resolveAssetUrl(asset), + metadata: (asset?.metadata as Record | null) || null, + }), [asset], ); - const currentObject = view.getObject(1) || view.getObjects()[0] || null; - const currentUrl = currentObject?.url ?? view.getPrimaryUrl() ?? null; - const currentMetadata = (currentObject?.metadata ?? view.getPrimaryMetadata()) || null; + const currentUrl = view.url || null; + const currentMetadata = view.metadata || null; const [isLoading, setIsLoading] = useState(false); diff --git a/frontend/src/utils/ocr.ts b/frontend/src/utils/ocr.ts index 0c6e4d6..c66b327 100644 --- a/frontend/src/utils/ocr.ts +++ b/frontend/src/utils/ocr.ts @@ -1,4 +1,4 @@ -import { createAssetView, resolveDocumentAssetUrl } from '../asset_manager'; +import { resolveDocumentAssetUrl, resolveAssetUrl } from '../asset_manager'; import type { DocumentLike as AssetManagerDocumentLike, DocumentVersionLike, @@ -59,8 +59,8 @@ export async function resolveOcrTextUrl({ return null; } - const baseView = createAssetView(asset); - const hasUrl = Boolean(baseView.getPrimaryUrl()); + const baseUrl = resolveAssetUrl(asset); + const hasUrl = Boolean(baseUrl); let entry: AssetLike = asset; if (ensureAssetUrl) { @@ -71,8 +71,7 @@ export async function resolveOcrTextUrl({ } } - const ensuredView = createAssetView(entry); - const directUrl = ensuredView.getPrimaryUrl(); + const directUrl = resolveAssetUrl(entry); if (directUrl) { return directUrl; } From 132d9e3d02c344c14f8a3810391ba6702ec6b3a7 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sat, 22 Nov 2025 02:41:14 +0100 Subject: [PATCH 05/17] fix --- frontend/src/detail/PreviewZoomOverlay.tsx | 39 ++++++++++---------- frontend/src/preview/DocumentViewerPanel.tsx | 9 +---- frontend/src/preview/PdfViewer.tsx | 4 +- frontend/src/styles/documents/viewer.css | 4 +- frontend/src/styles/preview/preview-zoom.css | 23 ++++++------ 5 files changed, 38 insertions(+), 41 deletions(-) diff --git a/frontend/src/detail/PreviewZoomOverlay.tsx b/frontend/src/detail/PreviewZoomOverlay.tsx index e7db638..010b3be 100644 --- a/frontend/src/detail/PreviewZoomOverlay.tsx +++ b/frontend/src/detail/PreviewZoomOverlay.tsx @@ -214,22 +214,22 @@ const PreviewZoomOverlay: React.FC = ({ const key = event.key; if (key === ' ' || key === 'Space' || key === 'Spacebar') { - const target = event.target; - if (target instanceof HTMLElement) { - const tag = target.tagName ? target.tagName.toLowerCase() : ''; - if ( - target.isContentEditable - || tag === 'input' - || tag === 'textarea' - || tag === 'select' - ) { - return; - } + const target = event.target; + if (target instanceof HTMLElement) { + const tag = target.tagName ? target.tagName.toLowerCase() : ''; + if ( + target.isContentEditable + || tag === 'input' + || tag === 'textarea' + || tag === 'select' + ) { + return; } - event.preventDefault(); - onClose(); - return; } + event.preventDefault(); + onClose(); + return; + } if (key === 'Escape') { event.preventDefault(); @@ -264,6 +264,7 @@ const PreviewZoomOverlay: React.FC = ({ if (isPdfDisplay) { return; } + event.stopPropagation(); toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0); }; @@ -322,11 +323,11 @@ const PreviewZoomOverlay: React.FC = ({ >
{ - if (event.target === event.currentTarget) { - onClose(); - } - }} + onClick={(event) => { + if (event.target === event.currentTarget) { + onClose(); + } + }} >
= ({ const headerLeadingButtons = isSidebarVariant ? [collapseButton, maximizeButton].filter(Boolean) : [sidebarToggle, closeButton].filter(Boolean); - const headerLeadingContent = headerLeadingButtons.length - ? ( - <> - {headerLeadingButtons} - - ) - : null; + const headerLeadingContent = headerLeadingButtons.length ? Children.toArray(headerLeadingButtons) : null; const resizeHandle = isSidebarVariant ? (