This commit is contained in:
2025-10-10 22:26:03 +02:00
parent 5fd4a41e64
commit 30f3fe015b
6 changed files with 739 additions and 100 deletions
+93
View File
@@ -29,6 +29,11 @@ pub struct EnsureFolderPathRequest {
pub segments: Vec<String>,
}
#[derive(Deserialize)]
pub struct UpdateFolderRequest {
pub parent_id: Option<Uuid>,
}
#[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<AppState>,
Path(folder_id): Path<Uuid>,
Json(payload): Json<UpdateFolderRequest>,
) -> AppResult<StatusCode> {
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::<Folder>(conn)
.optional()?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(&folder.name))
.filter(folders::id.ne(folder_id))
.first::<Folder>(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<Uuid>,
@@ -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<Folder> = 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(())
}
+4 -1
View File
@@ -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));
+43 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+545 -94
View File
@@ -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 }) => (
</svg>
);
const IconFolder = ({ className }) => (
<svg
className={className ? `icon ${className}` : 'icon'}
viewBox="0 0 24 24"
role="img"
aria-hidden="true"
focusable="false"
>
<path
d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
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 ? <IconChevronRight className="toggle-icon" /> : null;
const canDrag = node.id !== 'root';
const isDragging = draggingFolderId === node.id;
const handleToggleClick = (event) => {
event.stopPropagation();
@@ -195,13 +227,23 @@ const FolderNode = ({
};
return (
<li className="folder-node">
<li className={`folder-node${isDragging ? ' is-dragging' : ''}`}>
<div
className={`folder-row${isSelected ? ' active' : ''}`}
draggable={canDrag}
onClick={() => 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);
}
}}
>
<span
className={`toggle${canToggle ? '' : ' invisible'}${node.expanded ? ' expanded' : ''}`}
@@ -279,6 +321,7 @@ const FilterBar = ({
const DocumentsTable = ({
currentFolderName,
breadcrumbs,
loading,
onRefresh,
subfolders,
@@ -289,6 +332,9 @@ const DocumentsTable = ({
onFolderDrop,
onFolderDragOver,
onFolderDragLeave,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
onFolderDelete,
onDocumentSelect,
onDocumentOpen,
@@ -304,8 +350,30 @@ const DocumentsTable = ({
return (
<section className="documents-panel column">
<div className="column-header">
<div>
<h2>{currentFolderName}</h2>
<div className="column-header__titles">
<nav className="breadcrumb" aria-label="Folder breadcrumbs">
{breadcrumbs.map((crumb, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<span key={crumb.id} className="breadcrumb-item">
{isLast ? (
<span className="breadcrumb-current">{crumb.name}</span>
) : (
<a
href="#"
onClick={(event) => {
event.preventDefault();
onFolderSelect(crumb.id);
}}
>
{crumb.name}
</a>
)}
{!isLast && <span className="breadcrumb-separator"></span>}
</span>
);
})}
</nav>
{showingSearchResults && (
<div className="column-subtitle">Search results</div>
)}
@@ -337,17 +405,33 @@ const DocumentsTable = ({
</thead>
<tbody>
{!showingSearchResults &&
subfolders.map((folder) => (
<tr
key={folder.id}
className="folder"
onClick={() => 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 (
<tr
key={folder.id}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}`}
onClick={() => 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);
}
}}
>
<td className="thumb-cell">
<div className="thumb-placeholder">DIR</div>
<div className="thumb-placeholder icon">
<IconFolder className="icon-inline" />
</div>
</td>
<td>{folder.name}</td>
<td>Folder</td>
@@ -366,7 +450,8 @@ const DocumentsTable = ({
</button>
</td>
</tr>
))}
);
})}
{rows.map((doc) => (
<tr
key={doc.id}
@@ -578,7 +663,11 @@ const PreviewWorkspace = ({
<section className="preview-workspace">
<header className="preview-workspace__header">
<div className="preview-workspace__meta">
<button type="button" className="secondary" onClick={onClose}>
<button
type="button"
className="secondary"
onClick={() => onClose(detail?.document?.folder_id ?? document.folder_id ?? 'root')}
>
Back
</button>
<div>
@@ -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(
<span className="app-bar__hint">
{previewActive
? 'Viewing document preview. Press ← Back to return to the library.'
: `Drag files anywhere to upload into "${currentFolderName}".`}
: 'Drag files here to upload.'}
</span>
</div>
<div className="app-bar__actions">
@@ -1768,59 +2182,66 @@ const handleDownload = useCallback(
/>
) : (
<>
<Sidebar
folderNodes={folderNodes}
onToggle={folderClickHandlers.onToggle}
onSelect={folderClickHandlers.onSelect}
onDrop={folderClickHandlers.onDrop}
onDragOver={folderClickHandlers.onDragOver}
onDragLeave={folderClickHandlers.onDragLeave}
onCreateFolder={handleFolderCreate}
onDeleteFolder={handleFolderDelete}
disableActions={loading}
selectedFolder={selectedFolder}
/>
<DocumentsTable
currentFolderName={currentFolderName}
loading={loading}
onRefresh={refreshCurrentFolder}
subfolders={currentSubfolders}
documents={documents}
searchResults={searchResults}
isFilterActive={isFilterActive}
onFolderSelect={selectFolder}
onFolderDrop={folderClickHandlers.onDrop}
onFolderDragOver={folderClickHandlers.onDragOver}
onFolderDragLeave={folderClickHandlers.onDragLeave}
onFolderDelete={handleFolderDelete}
onDocumentSelect={(docId) => {
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}
/>
<DetailPanel
document={selectedDocument}
detail={selectedDetail}
tags={tags}
onDownload={handleDownload}
onTagAdd={handleTagAdd}
onTagRemove={handleTagRemove}
onRegenerateThumbnails={handleThumbnailRegeneration}
loading={loading}
<Sidebar
folderNodes={folderNodes}
onToggle={folderClickHandlers.onToggle}
onSelect={folderClickHandlers.onSelect}
onDrop={folderClickHandlers.onDrop}
onDragOver={folderClickHandlers.onDragOver}
onDragLeave={folderClickHandlers.onDragLeave}
onCreateFolder={handleFolderCreate}
onDeleteFolder={handleFolderDelete}
disableActions={loading}
selectedFolder={selectedFolder}
onFolderDragStart={handleFolderDragStart}
onFolderDragEnd={handleFolderDragEnd}
draggedFolderId={draggedFolderId}
/>
<DocumentsTable
currentFolderName={currentFolderName}
breadcrumbs={breadcrumbs}
loading={loading}
onRefresh={refreshCurrentFolder}
subfolders={currentSubfolders}
documents={documents}
searchResults={searchResults}
isFilterActive={isFilterActive}
onFolderSelect={selectFolder}
onFolderDrop={folderClickHandlers.onDrop}
onFolderDragOver={folderClickHandlers.onDragOver}
onFolderDragLeave={folderClickHandlers.onDragLeave}
onFolderDragStart={handleFolderDragStart}
onFolderDragEnd={handleFolderDragEnd}
draggedFolderId={draggedFolderId}
onFolderDelete={handleFolderDelete}
onDocumentSelect={(docId) => {
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}
/>
<DetailPanel
document={selectedDocument}
detail={selectedDetail}
tags={tags}
onDownload={handleDownload}
onTagAdd={handleTagAdd}
onTagRemove={handleTagRemove}
onRegenerateThumbnails={handleThumbnailRegeneration}
loading={loading}
previewEntry={selectedPreviewEntry}
onOpenPreview={openDocumentPreview}
/>
@@ -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 (
<App
routeFolderId={folderId}
routeDocumentId={documentId}
navigate={navigate}
/>
);
};
const AppRouter = () => (
<Routes>
<Route path="/" element={<Navigate to="/folders" replace />} />
<Route path="/folders" element={<RoutedApp />} />
<Route path="/folders/:folderId" element={<RoutedApp />} />
<Route path="/documents/:documentId" element={<RoutedApp />} />
<Route path="/folders/:folderId/documents/:documentId" element={<RoutedApp />} />
<Route path="*" element={<Navigate to="/folders" replace />} />
</Routes>
);
const container = document.getElementById('app');
const root = createRoot(container);
root.render(<App />);
root.render(
<HashRouter hashType="hashbang">
<AppRouter />
</HashRouter>,
);
+52 -3
View File
@@ -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%;