4
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
Generated
+43
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user