This commit is contained in:
2025-11-09 13:03:44 +01:00
parent 4bc7357e41
commit bc33f68209
24 changed files with 5665 additions and 5261 deletions
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { AppShellContext } from '../appShellContext';
import DropOverlay from './DropOverlay';
import useDocumentsWorkspace from './useDocumentsWorkspace';
import { useDocumentsPreferences } from './useDocumentsPreferences';
const DocumentsAppLayout = () => {
const documentsPreferences = useDocumentsPreferences();
const {
appStatus,
location,
shellRef,
dropOverlayState,
managementModals,
contextValue,
} = useDocumentsWorkspace({
documentsViewMode: documentsPreferences.documentsViewMode,
documentsSortField: documentsPreferences.documentsSortField,
documentsSortDirection: documentsPreferences.documentsSortDirection,
documentsSortFieldRef: documentsPreferences.documentsSortFieldRef,
documentsSortDirectionRef: documentsPreferences.documentsSortDirectionRef,
onDocumentsViewModeChange: documentsPreferences.handleDocumentsViewModeChange,
onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange,
onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle,
searchIncludeDescendants: documentsPreferences.searchIncludeDescendants,
onToggleSearchIncludeDescendants: documentsPreferences.toggleSearchIncludeDescendants,
onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants,
sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef,
deskHelpOpen: documentsPreferences.deskHelpOpen,
setDeskHelpOpen: documentsPreferences.setDeskHelpOpen,
handleDeskExit: documentsPreferences.handleDeskExit,
});
if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
const shouldRememberLastLocation = appStatus !== 'logged-out';
return (
<Navigate
to="/account/login"
replace
state={
shouldRememberLastLocation
? { from: location.pathname + location.search }
: undefined
}
/>
);
}
return (
<AppShellContext.Provider value={contextValue}>
<div className="app-shell" ref={shellRef}>
<DropOverlay
active={dropOverlayState.active}
folderName={dropOverlayState.folderName}
/>
<Outlet />
{managementModals}
</div>
</AppShellContext.Provider>
);
};
export default DocumentsAppLayout;
+132
View File
@@ -0,0 +1,132 @@
import { createAssetView } 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';
export const DEFAULT_SORT_FIELD = 'title';
export const DEFAULT_SORT_DIRECTION = 'asc';
export const SORT_FIELD_VALUES = ['title', 'issued_at', 'created_at', 'updated_at'];
export const TAG_FILTER_UNTAGGED = '__UNTAGGED__';
const ROW_KEY_SEPARATOR = ':';
const DOCUMENT_ROW_PREFIX = 'document';
const FOLDER_ROW_PREFIX = 'folder';
export const resolveApiPath = (path = '') => path;
const makeRowKey = (type, id) =>
id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`;
const getRowType = (key) => (typeof key === 'string' ? key.split(ROW_KEY_SEPARATOR, 1)[0] : '');
export const getRowId = (key) => {
if (typeof key !== 'string') return '';
const separatorIndex = key.indexOf(ROW_KEY_SEPARATOR);
if (separatorIndex === -1) return key;
return key.slice(separatorIndex + 1);
};
export const isDocumentRowKey = (key) => getRowType(key) === DOCUMENT_ROW_PREFIX;
export const isFolderRowKey = (key) => getRowType(key) === FOLDER_ROW_PREFIX;
export const resolveDocumentRowKey = (documentId) =>
documentId ? makeRowKey(DOCUMENT_ROW_PREFIX, documentId) : null;
export const resolveFolderRowKey = (folderId) =>
folderId ? makeRowKey(FOLDER_ROW_PREFIX, folderId) : null;
export const hasFiles = (event) =>
Array.from(event.dataTransfer?.types || []).includes('Files');
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 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 (
lhs.id === rhs.id
&& lhs.url === rhs.url
&& lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width
&& lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height
&& lhs.mime_type === rhs.mime_type
&& lhs.asset_type === rhs.asset_type
&& lhs.updated_at === rhs.updated_at
&& lhsCardinality === rhsCardinality
&& objectsComparable
);
};
const mergeAssetIntoGroup = (group, assetData) => {
if (!assetData || !assetData.asset_type) {
if (Array.isArray(group)) {
return group;
}
return group || {};
}
if (Array.isArray(group) || !group) {
const list = Array.isArray(group) ? group : [];
const index = list.findIndex((item) => item?.id === assetData.id);
if (index >= 0) {
const existing = list[index];
if (isAssetEquivalent(existing, assetData)) {
return list;
}
const next = list.slice();
next[index] = { ...existing, ...assetData };
return next;
}
return list.concat({ ...assetData });
}
const key = assetData.asset_type;
const previous = group?.[key];
if (previous && isAssetEquivalent(previous, assetData)) {
return group;
}
const next = { ...(group || {}) };
next[key] = { ...(previous || {}), ...assetData };
return next;
};
export const mergeAssetIntoDocument = (doc, assetData) => {
if (!doc) return doc;
const existingGroup = doc.current_version?.assets || null;
const nextGroup = mergeAssetIntoGroup(existingGroup, assetData);
if (nextGroup === existingGroup) {
return doc;
}
const updatedCurrentVersion = doc.current_version
? { ...doc.current_version, assets: nextGroup }
: { assets: nextGroup };
return { ...doc, current_version: updatedCurrentVersion };
};
export const createRootNode = () => ({
id: 'root',
name: DEFAULT_FOLDER_NAME,
parentId: null,
children: [],
expanded: true,
loaded: false,
hasChildren: false,
});
+1 -6
View File
@@ -1,10 +1,5 @@
import React, { useContext, useEffect, useMemo, useReducer } from 'react';
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
withCredentials: true,
});
import api from '../lib/api';
const storage = typeof window !== 'undefined' ? window.sessionStorage : undefined;
+221
View File
@@ -0,0 +1,221 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const useDocumentPreview = ({
routeDocumentId,
documents,
searchResults,
setDocuments,
selectedFolder,
assetManager,
api,
resolveApiPath,
notifyApiError,
navigate,
locationPathname,
locationSearch,
detailPanelControlRef,
setActivePreviewId,
}) => {
const [previewEntries, setPreviewEntries] = useState(() => new Map());
const previewInflightRef = useRef(new Map());
const previewReturnPathRef = useRef(null);
const resetPreviewState = useCallback(() => {
setPreviewEntries(() => new Map());
previewInflightRef.current = new Map();
previewReturnPathRef.current = null;
}, []);
const removePreviewEntries = useCallback((ids) => {
if (!Array.isArray(ids) || ids.length === 0) {
return;
}
setPreviewEntries((prev) => {
if (!prev.size) {
return prev;
}
let changed = false;
const next = new Map(prev);
ids.forEach((id) => {
if (next.delete(id)) {
changed = true;
}
previewInflightRef.current.delete(id);
});
return changed ? next : prev;
});
}, []);
const ensurePreviewUrl = useCallback(
async (documentId, { force = false } = {}) => {
if (!documentId) return null;
const existing = previewEntries.get(documentId) || null;
const now = Date.now();
const expiresAt = typeof existing?.expiresAt === 'number' ? existing.expiresAt : null;
if (!force && existing && (!expiresAt || expiresAt > now)) {
return existing;
}
if (!force && previewInflightRef.current.has(documentId)) {
return previewInflightRef.current.get(documentId);
}
const request = (async () => {
try {
const docResponse = await api.get(`/documents/${documentId}`);
const downloadPath = docResponse.data?.document?.current_version?.download_path;
if (!downloadPath || !resolveApiPath) {
throw new Error('Document missing download path');
}
const href = resolveApiPath(downloadPath);
const entry = {
url: href,
contentType: docResponse.data?.document?.current_version?.version?.content_type || null,
filename: docResponse.data?.document?.filename,
expiresAt: Date.now() + 5 * 60 * 1000,
};
setPreviewEntries((prev) => {
const next = new Map(prev);
next.set(documentId, entry);
return next;
});
return entry;
} catch (error) {
notifyApiError(error, 'Unable to fetch document preview.');
throw error;
} finally {
previewInflightRef.current.delete(documentId);
}
})();
previewInflightRef.current.set(documentId, request);
return request;
},
[previewEntries, api, resolveApiPath, notifyApiError, setPreviewEntries],
);
const ensurePreviewData = useCallback(
async (documentId) => {
if (!documentId) return null;
const findInCache = () => {
const pool = searchResults ?? documents;
return pool.find((item) => item.id === documentId) || null;
};
let doc = findInCache();
if (!doc) {
const { data } = await api.get(`/documents/${documentId}`);
const hydratedDetail = assetManager.hydrateDetail(data);
const fetched = hydratedDetail?.document || data.document || data;
doc = fetched ? assetManager.hydrateDocument(fetched) : null;
if (!doc) {
throw new Error('Document metadata unavailable.');
}
setDocuments((prev) => {
if (prev.some((item) => item.id === doc.id)) {
return prev;
}
return [doc, ...prev];
});
}
if (!previewReturnPathRef.current) {
const fallbackFolderId = doc?.folder_id || 'root';
previewReturnPathRef.current =
fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`;
}
await ensurePreviewUrl(documentId, { force: false });
setActivePreviewId(documentId);
return doc;
},
[
searchResults,
documents,
assetManager,
setDocuments,
ensurePreviewUrl,
setActivePreviewId,
api,
],
);
const openDocumentPreview = useCallback(
(documentId, { replace = false } = {}) => {
if (!documentId) return;
detailPanelControlRef.current.close();
previewReturnPathRef.current = `${locationPathname}${locationSearch}`;
navigate(`/documents/${documentId}`, { replace });
},
[navigate, locationPathname, locationSearch, detailPanelControlRef],
);
const closeDocumentPreview = useCallback(
(folderId = null) => {
const fallbackPath = previewReturnPathRef.current;
previewReturnPathRef.current = null;
if (fallbackPath) {
navigate(fallbackPath, { replace: false });
return;
}
const targetId = folderId || selectedFolder || 'root';
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
navigate(path, { replace: false });
},
[navigate, selectedFolder],
);
useEffect(() => {
if (!routeDocumentId) {
return undefined;
}
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
closeDocumentPreview();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [routeDocumentId, closeDocumentPreview]);
useEffect(() => {
if (!routeDocumentId) {
return undefined;
}
let cancelled = false;
ensurePreviewData(routeDocumentId).catch((error) => {
if (cancelled) {
return;
}
notifyApiError(error, 'Failed to open document preview.');
closeDocumentPreview();
});
return () => {
cancelled = true;
};
}, [routeDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]);
return {
previewEntries,
ensurePreviewUrl,
ensurePreviewData,
openDocumentPreview,
closeDocumentPreview,
resetPreviewState,
removePreviewEntries,
};
};
export default useDocumentPreview;
+138
View File
@@ -0,0 +1,138 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
DEFAULT_SORT_DIRECTION,
DEFAULT_SORT_FIELD,
SORT_FIELD_VALUES,
} from './appLayoutUtils';
const VIEW_MODE_STORAGE_KEY = 'papercrate_view_mode';
const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field';
const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction';
const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants';
const readSessionStorage = (key) => {
if (typeof window === 'undefined') {
return null;
}
try {
return window.sessionStorage.getItem(key);
} catch (error) {
console.warn(`[session-storage] failed to read ${key}`, error);
return null;
}
};
const writeSessionStorage = (key, value) => {
if (typeof window === 'undefined') {
return;
}
try {
window.sessionStorage.setItem(key, value);
} catch (error) {
console.warn(`[session-storage] failed to persist ${key}`, error);
}
};
export const useDocumentsPreferences = () => {
const [documentsViewMode, setDocumentsViewModeState] = useState(() => {
const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY);
if (stored === 'grid' || stored === 'desk') {
return stored;
}
return 'list';
});
const [deskHelpOpen, setDeskHelpOpen] = useState(false);
const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode);
useEffect(() => {
if (documentsViewMode !== 'desk') {
lastNonDeskViewRef.current = documentsViewMode;
} else if (deskHelpOpen) {
setDeskHelpOpen(false);
}
}, [documentsViewMode, deskHelpOpen]);
const setDocumentsViewMode = useCallback((mode) => {
const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list';
setDocumentsViewModeState((previous) => {
if (next !== previous) {
writeSessionStorage(VIEW_MODE_STORAGE_KEY, next);
}
return next;
});
}, []);
const handleDeskExit = useCallback(() => {
const fallback = lastNonDeskViewRef.current && lastNonDeskViewRef.current !== 'desk'
? lastNonDeskViewRef.current
: 'list';
setDocumentsViewMode(fallback);
}, [setDocumentsViewMode]);
const [documentsSortField, setDocumentsSortField] = useState(() => {
const stored = readSessionStorage(SORT_FIELD_STORAGE_KEY);
return SORT_FIELD_VALUES.includes(stored) ? stored : DEFAULT_SORT_FIELD;
});
const documentsSortFieldRef = useRef(documentsSortField);
useEffect(() => {
documentsSortFieldRef.current = documentsSortField;
writeSessionStorage(SORT_FIELD_STORAGE_KEY, documentsSortField);
}, [documentsSortField]);
const [documentsSortDirection, setDocumentsSortDirection] = useState(() => {
const stored = readSessionStorage(SORT_DIRECTION_STORAGE_KEY);
return stored === 'desc' || stored === 'asc' ? stored : DEFAULT_SORT_DIRECTION;
});
const documentsSortDirectionRef = useRef(documentsSortDirection);
useEffect(() => {
documentsSortDirectionRef.current = documentsSortDirection;
writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection);
}, [documentsSortDirection]);
const handleDocumentsSortFieldChange = useCallback((field) => {
const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD;
setDocumentsSortField((previous) => (previous === nextField ? previous : nextField));
}, []);
const handleDocumentsSortDirectionToggle = useCallback(() => {
setDocumentsSortDirection((previous) => (previous === 'asc' ? 'desc' : 'asc'));
}, []);
const [searchIncludeDescendants, setSearchIncludeDescendants] = useState(() => {
const stored = readSessionStorage(INCLUDE_DESCENDANTS_STORAGE_KEY);
if (stored === 'true') return true;
if (stored === 'false') return false;
return true;
});
useEffect(() => {
writeSessionStorage(
INCLUDE_DESCENDANTS_STORAGE_KEY,
searchIncludeDescendants ? 'true' : 'false',
);
}, [searchIncludeDescendants]);
const toggleSearchIncludeDescendants = useCallback(() => {
setSearchIncludeDescendants((previous) => !previous);
}, []);
const sortRefreshReadyRef = useRef(false);
return {
documentsViewMode,
handleDocumentsViewModeChange: setDocumentsViewMode,
handleDeskExit,
deskHelpOpen,
setDeskHelpOpen,
documentsSortField,
documentsSortDirection,
documentsSortFieldRef,
documentsSortDirectionRef,
handleDocumentsSortFieldChange,
handleDocumentsSortDirectionToggle,
searchIncludeDescendants,
setSearchIncludeDescendants,
toggleSearchIncludeDescendants,
sortRefreshReadyRef,
};
};
+238
View File
@@ -0,0 +1,238 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
TAG_FILTER_UNTAGGED,
resolveDocumentRowKey,
isDocumentRowKey,
} from './appLayoutUtils';
const useDocumentsSearch = ({
api,
assetManager,
token,
selectedFolder,
navigate,
locationPathname,
isDocumentsRoute,
selectionHelpers,
searchIncludeDescendants,
documentsSortField,
documentsSortDirection,
notifyApiError,
setLoading,
setSearchIncludeDescendants,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [activeTagFilters, setActiveTagFilters] = useState([]);
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
const [searchResults, setSearchResults] = useState(null);
const [searchLoading, setSearchLoading] = useState(false);
const toggleTagFilter = useCallback((tagId) => {
if (!tagId) return;
setActiveTagFilters((previous) => {
if (tagId === TAG_FILTER_UNTAGGED) {
return previous.includes(TAG_FILTER_UNTAGGED) ? [] : [TAG_FILTER_UNTAGGED];
}
const sanitized = previous.filter((id) => id !== TAG_FILTER_UNTAGGED);
if (sanitized.includes(tagId)) {
return sanitized.filter((id) => id !== tagId);
}
return sanitized.concat([tagId]);
});
}, []);
const toggleCorrespondentFilter = useCallback((correspondentId) => {
setActiveCorrespondentFilters((previous) => {
if (!correspondentId) {
return [];
}
return previous.includes(correspondentId) ? [] : [correspondentId];
});
}, []);
const isFilterActive = useMemo(
() =>
searchQuery.trim().length > 0
|| activeTagFilters.length > 0
|| activeCorrespondentFilters.length > 0,
[searchQuery, activeTagFilters, activeCorrespondentFilters],
);
const clearFilters = useCallback(() => {
setSearchQuery('');
setActiveTagFilters([]);
setActiveCorrespondentFilters([]);
setSearchLoading(false);
setSearchIncludeDescendants(true);
}, [
setSearchIncludeDescendants,
]);
const handleSearchChange = useCallback((value) => {
setSearchQuery(value);
}, []);
const handleSearchSubmit = useCallback(() => {
if (!navigate) return;
const targetFolder = selectedFolder && selectedFolder !== 'root' ? selectedFolder : 'root';
const targetPath = targetFolder === 'root' ? '/documents' : `/documents/folder/${targetFolder}`;
if (!isDocumentsRoute || locationPathname !== targetPath) {
navigate(targetPath, { replace: false });
}
}, [navigate, selectedFolder, isDocumentsRoute, locationPathname]);
useEffect(() => {
if (!token) return undefined;
if (!isFilterActive) {
setSearchResults(null);
setSearchLoading(false);
return undefined;
}
let cancelled = false;
let started = false;
setSearchLoading(true);
const debounce = setTimeout(async () => {
started = true;
setLoading(true);
try {
const params = {};
const trimmedQuery = searchQuery.trim();
if (trimmedQuery.length) {
params.query = trimmedQuery;
}
if (activeTagFilters.length) {
const onlyUntagged = activeTagFilters.length === 1
&& activeTagFilters[0] === TAG_FILTER_UNTAGGED;
if (onlyUntagged) {
params.tags = 'none';
} else {
const tagIds = activeTagFilters.filter((id) => id !== TAG_FILTER_UNTAGGED);
if (tagIds.length) {
params.tags = tagIds.join(',');
}
}
}
if (activeCorrespondentFilters.length) {
params.correspondents = activeCorrespondentFilters.join(',');
}
const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder;
if (folderIdentifier) {
params.folder_id = folderIdentifier;
}
if (!searchIncludeDescendants) {
params.include_descendants = false;
}
if (documentsSortField) {
params.sort = documentsSortField;
}
if (documentsSortDirection) {
params.dir = documentsSortDirection;
}
const { data } = await api.get('/documents', { params });
if (cancelled) return;
const results = assetManager.hydrateDocuments(data || []);
setSearchResults(results);
if (!results.length) {
setSearchLoading(false);
selectionHelpers.setSelectedEntries([]);
selectionHelpers.setFocusedDocumentId(null);
selectionHelpers.selectionOrderRef.current = [];
selectionHelpers.setSelectionOrder([]);
selectionHelpers.selectionAnchorRef.current = null;
return;
}
const resultKeys = results
.map((doc) => resolveDocumentRowKey(doc.id))
.filter(Boolean);
let targetKey = null;
let nextSelectionKeys = [];
selectionHelpers.setSelectedEntries((previous) => {
const previousDocKeys = previous.filter(isDocumentRowKey);
const filtered = previousDocKeys.filter((key) => resultKeys.includes(key));
if (filtered.length) {
targetKey = filtered[filtered.length - 1];
nextSelectionKeys = filtered;
return filtered;
}
targetKey = null;
nextSelectionKeys = [];
return [];
});
selectionHelpers.selectionOrderRef.current = nextSelectionKeys;
selectionHelpers.setSelectionOrder(nextSelectionKeys);
selectionHelpers.setFocusedDocumentId((previous) => {
if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) {
return previous;
}
return null;
});
selectionHelpers.selectionAnchorRef.current = targetKey;
} catch (error) {
if (cancelled) return;
notifyApiError(error, 'Search failed. Please try again.');
setSearchResults(null);
} finally {
if (!cancelled && started) {
setLoading(false);
setSearchLoading(false);
}
}
}, 300);
return () => {
cancelled = true;
clearTimeout(debounce);
if (started) {
setLoading(false);
setSearchLoading(false);
}
};
}, [
api,
token,
isFilterActive,
searchQuery,
activeTagFilters,
activeCorrespondentFilters,
searchIncludeDescendants,
documentsSortField,
documentsSortDirection,
selectedFolder,
notifyApiError,
assetManager,
selectionHelpers,
setLoading,
]);
return {
searchQuery,
setSearchQuery,
searchResults,
setSearchResults,
searchLoading,
setSearchLoading,
activeTagFilters,
setActiveTagFilters,
activeCorrespondentFilters,
setActiveCorrespondentFilters,
toggleTagFilter,
toggleCorrespondentFilter,
isFilterActive,
clearFilters,
handleSearchChange,
handleSearchSubmit,
};
};
export default useDocumentsSearch;
@@ -0,0 +1 @@
export { default } from '../hooks/documents/useDocumentsWorkspace';