From 30f3fe015b78ecac0cb967a6d67f691e5c646d65 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 10 Oct 2025 22:26:03 +0200 Subject: [PATCH] 4 --- backend/src/routes/folders.rs | 93 +++++ backend/src/routes/mod.rs | 5 +- frontend/package-lock.json | 44 ++- frontend/package.json | 3 +- frontend/src/index.jsx | 639 +++++++++++++++++++++++++++++----- frontend/src/styles.css | 55 ++- 6 files changed, 739 insertions(+), 100 deletions(-) diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index 55c00ac..d93aa5a 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -29,6 +29,11 @@ pub struct EnsureFolderPathRequest { pub segments: Vec, } +#[derive(Deserialize)] +pub struct UpdateFolderRequest { + pub parent_id: Option, +} + #[derive(Serialize)] pub struct FolderResponse { pub folder: FolderInfo, @@ -361,6 +366,78 @@ pub async fn delete_folder( Ok(StatusCode::NO_CONTENT) } +pub async fn update_folder_parent( + State(state): State, + Path(folder_id): Path, + Json(payload): Json, +) -> AppResult { + if payload.parent_id == Some(folder_id) { + return Err(AppError::bad_request("folder cannot be its own parent")); + } + + let mut conn = state.db()?; + + conn.transaction::<(), AppError, _>(|conn| { + let folder: Folder = folders::table.find(folder_id).first(conn)?; + let current_parent = folder.parent_id; + let next_parent = payload.parent_id; + + if current_parent == next_parent { + return Ok(()); + } + + if let Some(parent_id) = next_parent { + let _parent: Folder = folders::table.find(parent_id).first(conn)?; + } + + if let Some(parent_id) = next_parent { + let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?; + if descendant_ids.contains(&parent_id) { + return Err(AppError::bad_request( + "cannot move folder into itself or a descendant", + )); + } + } + + let conflict = if let Some(parent_id) = next_parent { + folders::table + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&folder.name)) + .filter(folders::id.ne(folder_id)) + .first::(conn) + .optional()? + } else { + folders::table + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&folder.name)) + .filter(folders::id.ne(folder_id)) + .first::(conn) + .optional()? + }; + + if conflict.is_some() { + return Err(AppError::bad_request( + "a folder with the same name already exists in the target", + )); + } + + let new_path = build_path_cache(conn, next_parent, &folder.name)?; + + diesel::update(folders::table.find(folder_id)) + .set(( + folders::parent_id.eq(next_parent), + folders::path_cache.eq(new_path), + )) + .execute(conn)?; + + refresh_descendant_paths(conn, folder_id)?; + + Ok(()) + })?; + + Ok(StatusCode::NO_CONTENT) +} + fn build_path_cache( conn: &mut PgConnection, parent_id: Option, @@ -406,3 +483,19 @@ fn gather_descendant_folder_ids(conn: &mut PgConnection, folder_id: Uuid) -> App Ok(ids) } + +fn refresh_descendant_paths(conn: &mut PgConnection, parent_id: Uuid) -> AppResult<()> { + let children: Vec = folders::table + .filter(folders::parent_id.eq(Some(parent_id))) + .load(conn)?; + + for child in children { + let path_cache = build_path_cache(conn, Some(parent_id), &child.name)?; + diesel::update(folders::table.find(child.id)) + .set(folders::path_cache.eq(path_cache)) + .execute(conn)?; + refresh_descendant_paths(conn, child.id)?; + } + + Ok(()) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 84e3e0e..b9bc610 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -47,7 +47,10 @@ pub fn create_router(state: AppState) -> Router<()> { let folders_routes = Router::new() .route("/", post(folders::create_folder)) .route("/path", post(folders::ensure_folder_path)) - .route("/:id", delete(folders::delete_folder)) + .route( + "/:id", + delete(folders::delete_folder).patch(folders::update_folder_parent), + ) .route("/:id/contents", get(folders::list_folder_contents)) .route("/:id/documents", get(folders::search_documents)); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9e8cd18..b21d709 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "axios": "1.7.7", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "react-router-dom": "6.27.0" }, "devDependencies": { "@babel/core": "7.26.0", @@ -1953,6 +1954,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@remix-run/router": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz", + "integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -5482,6 +5492,38 @@ "react": "^18.3.1" } }, + "node_modules/react-router": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz", + "integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.20.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz", + "integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.20.0", + "react-router": "6.27.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1957ce4..337c52a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ "dependencies": { "axios": "1.7.7", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "react-router-dom": "6.27.0" }, "devDependencies": { "@babel/core": "7.26.0", diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 38655ca..47589ed 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -7,6 +7,14 @@ import React, { } from 'react'; import { createRoot } from 'react-dom/client'; import axios from 'axios'; +import { + HashRouter, + Navigate, + Route, + Routes, + useNavigate, + useParams, +} from 'react-router-dom'; import './styles.css'; const API_ROOT = (process.env.API_BASE_URL || '').replace(/\/$/, ''); @@ -117,6 +125,25 @@ const IconDownload = ({ className }) => ( ); +const IconFolder = ({ className }) => ( + +); + const createRootNode = () => ({ id: 'root', name: DEFAULT_FOLDER_NAME, @@ -183,9 +210,14 @@ const FolderNode = ({ onDelete, disableActions, renderChildren, + onFolderDragStart, + onFolderDragEnd, + draggingFolderId, }) => { const canToggle = node.id === 'root' || node.children.length > 0 || !node.loaded; const icon = canToggle ? : null; + const canDrag = node.id !== 'root'; + const isDragging = draggingFolderId === node.id; const handleToggleClick = (event) => { event.stopPropagation(); @@ -195,13 +227,23 @@ const FolderNode = ({ }; return ( -
  • +
  • onSelect(node.id)} onDragOver={(event) => onDragOver(event, node.id)} onDragLeave={onDragLeave} onDrop={(event) => onDrop(event, node.id)} + onDragStart={(event) => { + if (!canDrag || !onFolderDragStart) return; + onFolderDragStart(event, node.id); + }} + onDragEnd={(event) => { + if (onFolderDragEnd) { + onFolderDragEnd(event); + } + }} >
    -
    -

    {currentFolderName}

    +
    + {showingSearchResults && (
    Search results
    )} @@ -337,17 +405,33 @@ const DocumentsTable = ({ {!showingSearchResults && - subfolders.map((folder) => ( - onFolderSelect(folder.id)} - onDragOver={(event) => onFolderDragOver(event, folder.id)} - onDragLeave={onFolderDragLeave} - onDrop={(event) => onFolderDrop(event, folder.id)} - > + subfolders.map((folder) => { + const canDragFolder = folder.id !== 'root'; + const isDraggingFolder = draggedFolderId === folder.id; + return ( + onFolderSelect(folder.id)} + onDragOver={(event) => onFolderDragOver(event, folder.id)} + onDragLeave={onFolderDragLeave} + onDrop={(event) => onFolderDrop(event, folder.id)} + draggable={canDragFolder} + onDragStart={(event) => { + if (canDragFolder) { + onFolderDragStart(event, folder.id); + } + }} + onDragEnd={(event) => { + if (canDragFolder) { + onFolderDragEnd(event); + } + }} + > -
    DIR
    +
    + +
    {folder.name} Folder @@ -366,7 +450,8 @@ const DocumentsTable = ({ - ))} + ); + })} {rows.map((doc) => (
    -
    @@ -638,6 +727,9 @@ const Sidebar = ({ onDeleteFolder, disableActions, selectedFolder, + onFolderDragStart, + onFolderDragEnd, + draggedFolderId, }) => { const renderNodes = useCallback( (ids, depth) => @@ -658,10 +750,26 @@ const Sidebar = ({ onDelete={onDeleteFolder} disableActions={disableActions} renderChildren={renderNodes} + onFolderDragStart={onFolderDragStart} + onFolderDragEnd={onFolderDragEnd} + draggingFolderId={draggedFolderId} /> ); }), - [folderNodes, selectedFolder, onToggle, onSelect, onDrop, onDragOver, onDragLeave, onDeleteFolder, disableActions], + [ + folderNodes, + selectedFolder, + onToggle, + onSelect, + onDrop, + onDragOver, + onDragLeave, + onDeleteFolder, + disableActions, + onFolderDragStart, + onFolderDragEnd, + draggedFolderId, + ], ); const rootNode = folderNodes.get('root'); @@ -699,7 +807,7 @@ const Sidebar = ({ ); }; -function App() { +function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) { const [token, setToken] = useState(() => window.localStorage.getItem('paperless_token') || ''); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(false); @@ -708,11 +816,11 @@ function App() { return new Map([[rootNode.id, rootNode]]); }); const [folderContents, setFolderContents] = useState(() => new Map()); - const [selectedFolder, setSelectedFolder] = useState('root'); + const [selectedFolder, setSelectedFolder] = useState(routeFolderId || 'root'); const [currentFolder, setCurrentFolder] = useState(null); const [currentSubfolders, setCurrentSubfolders] = useState([]); const [documents, setDocuments] = useState([]); - const [selectedDocumentId, setSelectedDocumentId] = useState(null); + const [selectedDocumentId, setSelectedDocumentId] = useState(routeDocumentId); const [documentDetails, setDocumentDetails] = useState(() => new Map()); const documentDetailsRef = useRef(documentDetails); const [searchResults, setSearchResults] = useState(null); @@ -720,6 +828,7 @@ function App() { const [searchQuery, setSearchQuery] = useState(''); const [activeTagFilters, setActiveTagFilters] = useState([]); const [draggedDocumentId, setDraggedDocumentId] = useState(null); + const [draggedFolderId, setDraggedFolderId] = useState(null); const [dropOverlayState, setDropOverlayState] = useState({ active: false, folderName: DEFAULT_FOLDER_NAME, @@ -836,6 +945,147 @@ function App() { [folderContents], ); + const isInvalidFolderDrop = useCallback( + (sourceId, targetId) => { + if (!sourceId) return false; + if (!targetId || targetId === 'root') { + return false; + } + if (sourceId === targetId) { + return true; + } + + let current = targetId; + const visited = new Set(); + while (current && current !== 'root' && !visited.has(current)) { + visited.add(current); + if (current === sourceId) { + return true; + } + const node = folderNodes.get(current); + if (!node) break; + current = node.parentId ?? 'root'; + } + return false; + }, + [folderNodes], + ); + + const moveFolder = useCallback( + async (folderId, targetFolderId) => { + const node = folderNodes.get(folderId); + if (!node) { + setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error'); + return; + } + + const previousParentKey = node.parentId ?? 'root'; + const targetKey = targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root'; + + if (previousParentKey === targetKey) { + return; + } + + const parent_id = targetKey === 'root' ? null : targetKey; + + try { + await api.patch(`/folders/${folderId}`, { parent_id }); + + setFolderNodes((prev) => { + const next = new Map(prev); + const currentNode = next.get(folderId); + if (!currentNode) { + return prev; + } + + const updatedNode = { ...currentNode, parentId: parent_id ?? null }; + next.set(folderId, updatedNode); + + const previousParent = next.get(previousParentKey); + if (previousParent) { + next.set(previousParentKey, { + ...previousParent, + children: (previousParent.children || []).filter((childId) => childId !== folderId), + }); + } + + if (!next.has(targetKey)) { + next.set(targetKey, { + id: targetKey, + name: targetKey === 'root' ? DEFAULT_FOLDER_NAME : 'Folder', + parentId: targetKey === 'root' ? null : null, + children: [], + expanded: targetKey === 'root', + loaded: false, + }); + } + + const targetNode = next.get(targetKey); + if (targetNode && !targetNode.children.includes(folderId)) { + next.set(targetKey, { + ...targetNode, + children: [...targetNode.children, folderId], + }); + } + + return next; + }); + + const refreshTargets = new Set([previousParentKey, targetKey]); + for (const key of refreshTargets) { + if (key === 'root') { + await ensureFolderData('root', { force: true }); + } else { + await ensureFolderData(key, { force: true }); + } + } + + if (selectedFolder === folderId) { + await ensureFolderData(folderId, { force: true }); + setSelectedFolder(folderId); + } + + setStatusMessage('Folder moved.', 'success'); + } catch (error) { + console.error(error); + const message = error.response?.data?.error || 'Failed to move folder.'; + setStatusMessage(message, 'error'); + + const refreshTargets = new Set([previousParentKey, targetKey]); + for (const key of refreshTargets) { + if (key === 'root') { + await ensureFolderData('root', { force: true }); + } else { + await ensureFolderData(key, { force: true }); + } + } + } + }, + [api, folderNodes, ensureFolderData, selectedFolder, setSelectedFolder, setStatusMessage, setFolderNodes], + ); + + const handleFolderDragStart = useCallback( + (event, folderId) => { + if (folderId === 'root') { + return; + } + event.stopPropagation(); + setDraggedFolderId(folderId); + event.dataTransfer.effectAllowed = 'move'; + try { + event.dataTransfer.setData('application/x-paperless-folder', folderId); + } catch ( + // eslint-disable-next-line no-empty + error + ) {} + }, + [setDraggedFolderId], + ); + + const handleFolderDragEnd = useCallback(() => { + setDraggedFolderId(null); + }, [setDraggedFolderId]); + const ensureDocumentDetail = useCallback( async (documentId, { force = false } = {}) => { if (!documentId) return null; @@ -876,13 +1126,14 @@ function App() { } }, [setStatusMessage]); - const selectFolder = useCallback( - async (folderId) => { - setSelectedFolder(folderId); - setLoading(true); + const loadFolder = useCallback( + async (folderId, { showLoading = true } = {}) => { + const targetId = folderId || 'root'; + setSelectedFolder(targetId); + if (showLoading) setLoading(true); try { - const contents = await ensureFolderData(folderId, { force: true }); - const nextSelectedId = applySelectedFolder(folderId, contents); + const contents = await ensureFolderData(targetId, { force: true }); + const nextSelectedId = applySelectedFolder(targetId, contents); setSearchResults(null); if (nextSelectedId) { await ensureDocumentDetail(nextSelectedId, { force: true }); @@ -891,12 +1142,27 @@ function App() { console.error(error); setStatusMessage('Failed to load folder contents.', 'error'); } finally { - setLoading(false); + if (showLoading) setLoading(false); } }, [ensureFolderData, applySelectedFolder, ensureDocumentDetail, setStatusMessage], ); + const selectFolder = useCallback( + async (folderId, { replace = false, immediate = false } = {}) => { + const targetId = folderId && folderId !== 'root' ? folderId : 'root'; + + if (!navigate || immediate) { + await loadFolder(targetId); + return; + } + + const path = targetId === 'root' ? '/folders' : `/folders/${targetId}`; + navigate(path, { replace }); + }, + [loadFolder, navigate], + ); + const initializeAfterLogin = useCallback(async () => { setLoading(true); try { @@ -914,6 +1180,21 @@ function App() { } }, [refreshTags, ensureFolderData, applySelectedFolder, ensureDocumentDetail, setStatusMessage]); + useEffect(() => { + if (routeFolderId === undefined) { + return; + } + + if ((routeFolderId === null || routeFolderId === 'root') && routeDocumentId) { + return; + } + + const targetId = routeFolderId || 'root'; + if (targetId !== selectedFolder) { + loadFolder(targetId, { showLoading: true }); + } + }, [routeFolderId, routeDocumentId, selectedFolder, loadFolder]); + const refreshCurrentFolder = useCallback(async () => { setLoading(true); try { @@ -1229,13 +1510,17 @@ const handleDownload = useCallback( ); const openDocumentPreview = useCallback( - async (documentId) => { + async (documentId, { replace = false, skipNavigate = false } = {}) => { if (!documentId) return; setPreviewDocumentId(documentId); setPreviewDocumentLoading(true); try { await ensureDocumentDetail(documentId, { force: false }); await ensurePreviewUrl(documentId, { force: false }); + setSelectedDocumentId(documentId); + if (!skipNavigate && navigate) { + navigate(`/documents/${documentId}`, { replace }); + } } catch (error) { console.error(error); setPreviewDocumentId(null); @@ -1243,13 +1528,26 @@ const handleDownload = useCallback( setPreviewDocumentLoading(false); } }, - [ensureDocumentDetail, ensurePreviewUrl], + [ensureDocumentDetail, ensurePreviewUrl, navigate], ); - const closeDocumentPreview = useCallback(() => { - setPreviewDocumentId(null); - setPreviewDocumentLoading(false); - }, []); + const closeDocumentPreview = useCallback( + (folderId = null) => { + setPreviewDocumentId(null); + setPreviewDocumentLoading(false); + + const targetId = folderId || selectedFolder || 'root'; + + if (!navigate) { + loadFolder(targetId, { showLoading: false }); + return; + } + + const path = targetId === 'root' ? '/folders' : `/folders/${targetId}`; + navigate(path, { replace: false }); + }, + [navigate, selectedFolder, loadFolder], + ); useEffect(() => { if (!previewDocumentId) return; @@ -1262,6 +1560,52 @@ const handleDownload = useCallback( return () => window.removeEventListener('keydown', handleKeyDown); }, [previewDocumentId, closeDocumentPreview]); + useEffect(() => { + if (!routeDocumentId) { + if (previewDocumentId) { + closeDocumentPreview(); + } + return; + } + + let cancelled = false; + + const hydratePreview = async () => { + try { + const detail = await ensureDocumentDetail(routeDocumentId, { force: true }); + const doc = detail?.document || documentDetailsRef.current.get(routeDocumentId)?.document; + const targetFolder = doc?.folder_id || routeFolderId || 'root'; + if (targetFolder && targetFolder !== selectedFolder) { + await loadFolder(targetFolder, { showLoading: false }); + } + if (!cancelled) { + await openDocumentPreview(routeDocumentId, { replace: true, skipNavigate: true }); + } + } catch (error) { + if (!cancelled) { + console.error(error); + setStatusMessage('Failed to open document preview.', 'error'); + } + } + }; + + hydratePreview(); + + return () => { + cancelled = true; + }; + }, [ + routeDocumentId, + routeFolderId, + ensureDocumentDetail, + selectedFolder, + loadFolder, + openDocumentPreview, + previewDocumentId, + closeDocumentPreview, + setStatusMessage, + ]); + const handleTagRemove = useCallback( async (documentId, tagId) => { try { @@ -1636,19 +1980,60 @@ const handleDownload = useCallback( onSelect: selectFolder, onDrop: async (event, folderId) => { event.preventDefault(); + event.stopPropagation(); event.currentTarget.classList.remove('is-drop-target'); - try { - await handleFileDrop(event.dataTransfer, folderId); - } finally { - // no-op; errors handled inside handleFileDrop + + let folderSourceId = draggedFolderId; + if (!folderSourceId) { + try { + if (event.dataTransfer.types?.includes('application/x-paperless-folder')) { + folderSourceId = event.dataTransfer.getData('application/x-paperless-folder'); + } + } catch ( + // eslint-disable-next-line no-empty + error + ) {} } - const documentId = event.dataTransfer.getData('application/x-paperless-doc') || draggedDocumentId; + + if (folderSourceId) { + setDraggedFolderId(null); + if (isInvalidFolderDrop(folderSourceId, folderId)) { + setStatusMessage( + 'Cannot move a folder into itself or one of its descendants.', + 'error', + ); + return; + } + await moveFolder(folderSourceId, folderId); + return; + } + + if (hasFiles(event)) { + await handleFileDrop(event.dataTransfer, folderId); + return; + } + + const documentId = + event.dataTransfer.getData('application/x-paperless-doc') || draggedDocumentId; if (!documentId || folderId === selectedFolder) return; moveDocumentToFolder(documentId, folderId); }, - onDragOver: (event) => { - if (hasFiles(event) || draggedDocumentId) { + onDragOver: (event, folderId) => { + const folderDragActive = Boolean(draggedFolderId); + if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) { + return; + } + + if (hasFiles(event)) { event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + event.currentTarget.classList.add('is-drop-target'); + return; + } + + if (draggedDocumentId || folderDragActive) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; event.currentTarget.classList.add('is-drop-target'); } }, @@ -1687,6 +2072,35 @@ const handleDownload = useCallback( ? documentDetails.get(selectedDocument.id) : null; + const breadcrumbs = useMemo(() => { + const chain = []; + const seen = new Set(); + let currentId = selectedFolder || 'root'; + + while (currentId && !seen.has(currentId)) { + seen.add(currentId); + if (currentId === 'root') { + chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); + break; + } + + const node = folderNodes.get(currentId); + if (node) { + chain.push({ id: currentId, name: node.name || DEFAULT_FOLDER_NAME }); + currentId = node.parentId ?? 'root'; + } else { + chain.push({ id: currentId, name: currentFolderName }); + currentId = 'root'; + } + } + + if (chain[chain.length - 1]?.id !== 'root') { + chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); + } + + return chain.reverse(); + }, [selectedFolder, folderNodes, currentFolderName]); + useEffect(() => { if (!selectedDocument) return; ensurePreviewUrl(selectedDocument.id).catch(() => {}); @@ -1733,7 +2147,7 @@ const handleDownload = useCallback( {previewActive ? 'Viewing document preview. Press ← Back to return to the library.' - : `Drag files anywhere to upload into "${currentFolderName}".`} + : 'Drag files here to upload.'}
    @@ -1768,59 +2182,66 @@ const handleDownload = useCallback( /> ) : ( <> - - { - setSelectedDocumentId(docId); - ensureDocumentDetail(docId); - }} - onDocumentOpen={openDocumentPreview} - selectedDocumentId={selectedDocumentId} - onDocumentDragStart={(event, documentId) => { - setDraggedDocumentId(documentId); - event.dataTransfer.effectAllowed = 'move'; - event.dataTransfer.setData('application/x-paperless-doc', documentId); - event.currentTarget.classList.add('dragging'); - }} - onDocumentDragEnd={(event) => { - setDraggedDocumentId(null); - event.currentTarget.classList.remove('dragging'); - }} - onDownload={handleDownload} - filterBar={filterBar} - /> - + { + setSelectedDocumentId(docId); + ensureDocumentDetail(docId); + }} + onDocumentOpen={openDocumentPreview} + selectedDocumentId={selectedDocumentId} + onDocumentDragStart={(event, documentId) => { + setDraggedDocumentId(documentId); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('application/x-paperless-doc', documentId); + event.currentTarget.classList.add('dragging'); + }} + onDocumentDragEnd={(event) => { + setDraggedDocumentId(null); + event.currentTarget.classList.remove('dragging'); + }} + onDownload={handleDownload} + filterBar={filterBar} + /> + @@ -1835,6 +2256,36 @@ const handleDownload = useCallback( ); } +const RoutedApp = () => { + const navigate = useNavigate(); + const params = useParams(); + const folderId = params.folderId ?? null; + const documentId = params.documentId ?? null; + + return ( + + ); +}; + +const AppRouter = () => ( + + } /> + } /> + } /> + } /> + } /> + } /> + +); + const container = document.getElementById('app'); const root = createRoot(container); -root.render(); +root.render( + + + , +); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 63ddf4d..b9ce793 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -278,6 +278,45 @@ button.icon-button.ghost:hover:not([disabled]) { border-bottom: 1px solid var(--border); } +.column-header__titles { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.breadcrumb { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.85rem; + color: var(--muted); +} + +.breadcrumb-item { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.breadcrumb-item a { + text-decoration: none; + color: var(--accent); +} + +.breadcrumb-item a:hover, +.breadcrumb-item a:focus { + text-decoration: underline; +} + +.breadcrumb-current { + font-weight: 600; + color: var(--fg); +} + +.breadcrumb-separator { + color: var(--muted); +} + .column-header h2 { margin: 0; font-size: 0.9rem; @@ -438,7 +477,6 @@ button.icon-button.ghost:hover:not([disabled]) { .documents-panel thead th { position: sticky; top: 0; - background: var(--surface); z-index: 1; padding: 0.45rem 0.6rem; font-size: 0.75rem; @@ -465,14 +503,13 @@ button.icon-button.ghost:hover:not([disabled]) { height: 48px; object-fit: cover; border-radius: 3px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24); } .thumb-placeholder { width: 36px; height: 48px; border-radius: 0; - background: var(--surface-subtle); color: var(--muted); display: inline-flex; align-items: center; @@ -482,6 +519,18 @@ button.icon-button.ghost:hover:not([disabled]) { letter-spacing: 0.08em; } +.thumb-placeholder.icon { + font-size: 1.8rem; + color: var(--accent); + width: 36px; + height: 48px; +} + +.thumb-placeholder.icon svg { + width: 32px; + height: 32px; +} + .documents-panel th.actions-column, .documents-panel td.actions { width: 1%;