4
This commit is contained in:
@@ -29,6 +29,11 @@ pub struct EnsureFolderPathRequest {
|
|||||||
pub segments: Vec<String>,
|
pub segments: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateFolderRequest {
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct FolderResponse {
|
pub struct FolderResponse {
|
||||||
pub folder: FolderInfo,
|
pub folder: FolderInfo,
|
||||||
@@ -361,6 +366,78 @@ pub async fn delete_folder(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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(
|
fn build_path_cache(
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
parent_id: Option<Uuid>,
|
parent_id: Option<Uuid>,
|
||||||
@@ -406,3 +483,19 @@ fn gather_descendant_folder_ids(conn: &mut PgConnection, folder_id: Uuid) -> App
|
|||||||
|
|
||||||
Ok(ids)
|
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()
|
let folders_routes = Router::new()
|
||||||
.route("/", post(folders::create_folder))
|
.route("/", post(folders::create_folder))
|
||||||
.route("/path", post(folders::ensure_folder_path))
|
.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/contents", get(folders::list_folder_contents))
|
||||||
.route("/:id/documents", get(folders::search_documents));
|
.route("/:id/documents", get(folders::search_documents));
|
||||||
|
|
||||||
|
|||||||
Generated
+43
-1
@@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "1.7.7",
|
"axios": "1.7.7",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1",
|
||||||
|
"react-router-dom": "6.27.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.26.0",
|
"@babel/core": "7.26.0",
|
||||||
@@ -1953,6 +1954,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -5482,6 +5492,38 @@
|
|||||||
"react": "^18.3.1"
|
"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": {
|
"node_modules/readable-stream": {
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "1.7.7",
|
"axios": "1.7.7",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1",
|
||||||
|
"react-router-dom": "6.27.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.26.0",
|
"@babel/core": "7.26.0",
|
||||||
|
|||||||
+482
-31
@@ -7,6 +7,14 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import {
|
||||||
|
HashRouter,
|
||||||
|
Navigate,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useNavigate,
|
||||||
|
useParams,
|
||||||
|
} from 'react-router-dom';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
const API_ROOT = (process.env.API_BASE_URL || '').replace(/\/$/, '');
|
const API_ROOT = (process.env.API_BASE_URL || '').replace(/\/$/, '');
|
||||||
@@ -117,6 +125,25 @@ const IconDownload = ({ className }) => (
|
|||||||
</svg>
|
</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 = () => ({
|
const createRootNode = () => ({
|
||||||
id: 'root',
|
id: 'root',
|
||||||
name: DEFAULT_FOLDER_NAME,
|
name: DEFAULT_FOLDER_NAME,
|
||||||
@@ -183,9 +210,14 @@ const FolderNode = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
disableActions,
|
disableActions,
|
||||||
renderChildren,
|
renderChildren,
|
||||||
|
onFolderDragStart,
|
||||||
|
onFolderDragEnd,
|
||||||
|
draggingFolderId,
|
||||||
}) => {
|
}) => {
|
||||||
const canToggle = node.id === 'root' || node.children.length > 0 || !node.loaded;
|
const canToggle = node.id === 'root' || node.children.length > 0 || !node.loaded;
|
||||||
const icon = canToggle ? <IconChevronRight className="toggle-icon" /> : null;
|
const icon = canToggle ? <IconChevronRight className="toggle-icon" /> : null;
|
||||||
|
const canDrag = node.id !== 'root';
|
||||||
|
const isDragging = draggingFolderId === node.id;
|
||||||
|
|
||||||
const handleToggleClick = (event) => {
|
const handleToggleClick = (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -195,13 +227,23 @@ const FolderNode = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="folder-node">
|
<li className={`folder-node${isDragging ? ' is-dragging' : ''}`}>
|
||||||
<div
|
<div
|
||||||
className={`folder-row${isSelected ? ' active' : ''}`}
|
className={`folder-row${isSelected ? ' active' : ''}`}
|
||||||
|
draggable={canDrag}
|
||||||
onClick={() => onSelect(node.id)}
|
onClick={() => onSelect(node.id)}
|
||||||
onDragOver={(event) => onDragOver(event, node.id)}
|
onDragOver={(event) => onDragOver(event, node.id)}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
onDrop={(event) => onDrop(event, node.id)}
|
onDrop={(event) => onDrop(event, node.id)}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (!canDrag || !onFolderDragStart) return;
|
||||||
|
onFolderDragStart(event, node.id);
|
||||||
|
}}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
if (onFolderDragEnd) {
|
||||||
|
onFolderDragEnd(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`toggle${canToggle ? '' : ' invisible'}${node.expanded ? ' expanded' : ''}`}
|
className={`toggle${canToggle ? '' : ' invisible'}${node.expanded ? ' expanded' : ''}`}
|
||||||
@@ -279,6 +321,7 @@ const FilterBar = ({
|
|||||||
|
|
||||||
const DocumentsTable = ({
|
const DocumentsTable = ({
|
||||||
currentFolderName,
|
currentFolderName,
|
||||||
|
breadcrumbs,
|
||||||
loading,
|
loading,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
subfolders,
|
subfolders,
|
||||||
@@ -289,6 +332,9 @@ const DocumentsTable = ({
|
|||||||
onFolderDrop,
|
onFolderDrop,
|
||||||
onFolderDragOver,
|
onFolderDragOver,
|
||||||
onFolderDragLeave,
|
onFolderDragLeave,
|
||||||
|
onFolderDragStart,
|
||||||
|
onFolderDragEnd,
|
||||||
|
draggedFolderId,
|
||||||
onFolderDelete,
|
onFolderDelete,
|
||||||
onDocumentSelect,
|
onDocumentSelect,
|
||||||
onDocumentOpen,
|
onDocumentOpen,
|
||||||
@@ -304,8 +350,30 @@ const DocumentsTable = ({
|
|||||||
return (
|
return (
|
||||||
<section className="documents-panel column">
|
<section className="documents-panel column">
|
||||||
<div className="column-header">
|
<div className="column-header">
|
||||||
<div>
|
<div className="column-header__titles">
|
||||||
<h2>{currentFolderName}</h2>
|
<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 && (
|
{showingSearchResults && (
|
||||||
<div className="column-subtitle">Search results</div>
|
<div className="column-subtitle">Search results</div>
|
||||||
)}
|
)}
|
||||||
@@ -337,17 +405,33 @@ const DocumentsTable = ({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{!showingSearchResults &&
|
{!showingSearchResults &&
|
||||||
subfolders.map((folder) => (
|
subfolders.map((folder) => {
|
||||||
|
const canDragFolder = folder.id !== 'root';
|
||||||
|
const isDraggingFolder = draggedFolderId === folder.id;
|
||||||
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
className="folder"
|
className={`folder${isDraggingFolder ? ' is-dragging' : ''}`}
|
||||||
onClick={() => onFolderSelect(folder.id)}
|
onClick={() => onFolderSelect(folder.id)}
|
||||||
onDragOver={(event) => onFolderDragOver(event, folder.id)}
|
onDragOver={(event) => onFolderDragOver(event, folder.id)}
|
||||||
onDragLeave={onFolderDragLeave}
|
onDragLeave={onFolderDragLeave}
|
||||||
onDrop={(event) => onFolderDrop(event, folder.id)}
|
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">
|
<td className="thumb-cell">
|
||||||
<div className="thumb-placeholder">DIR</div>
|
<div className="thumb-placeholder icon">
|
||||||
|
<IconFolder className="icon-inline" />
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{folder.name}</td>
|
<td>{folder.name}</td>
|
||||||
<td>Folder</td>
|
<td>Folder</td>
|
||||||
@@ -366,7 +450,8 @@ const DocumentsTable = ({
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{rows.map((doc) => (
|
{rows.map((doc) => (
|
||||||
<tr
|
<tr
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
@@ -578,7 +663,11 @@ const PreviewWorkspace = ({
|
|||||||
<section className="preview-workspace">
|
<section className="preview-workspace">
|
||||||
<header className="preview-workspace__header">
|
<header className="preview-workspace__header">
|
||||||
<div className="preview-workspace__meta">
|
<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
|
← Back
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
@@ -638,6 +727,9 @@ const Sidebar = ({
|
|||||||
onDeleteFolder,
|
onDeleteFolder,
|
||||||
disableActions,
|
disableActions,
|
||||||
selectedFolder,
|
selectedFolder,
|
||||||
|
onFolderDragStart,
|
||||||
|
onFolderDragEnd,
|
||||||
|
draggedFolderId,
|
||||||
}) => {
|
}) => {
|
||||||
const renderNodes = useCallback(
|
const renderNodes = useCallback(
|
||||||
(ids, depth) =>
|
(ids, depth) =>
|
||||||
@@ -658,10 +750,26 @@ const Sidebar = ({
|
|||||||
onDelete={onDeleteFolder}
|
onDelete={onDeleteFolder}
|
||||||
disableActions={disableActions}
|
disableActions={disableActions}
|
||||||
renderChildren={renderNodes}
|
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');
|
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 [token, setToken] = useState(() => window.localStorage.getItem('paperless_token') || '');
|
||||||
const [status, setStatus] = useState(null);
|
const [status, setStatus] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -708,11 +816,11 @@ function App() {
|
|||||||
return new Map([[rootNode.id, rootNode]]);
|
return new Map([[rootNode.id, rootNode]]);
|
||||||
});
|
});
|
||||||
const [folderContents, setFolderContents] = useState(() => new Map());
|
const [folderContents, setFolderContents] = useState(() => new Map());
|
||||||
const [selectedFolder, setSelectedFolder] = useState('root');
|
const [selectedFolder, setSelectedFolder] = useState(routeFolderId || 'root');
|
||||||
const [currentFolder, setCurrentFolder] = useState(null);
|
const [currentFolder, setCurrentFolder] = useState(null);
|
||||||
const [currentSubfolders, setCurrentSubfolders] = useState([]);
|
const [currentSubfolders, setCurrentSubfolders] = useState([]);
|
||||||
const [documents, setDocuments] = useState([]);
|
const [documents, setDocuments] = useState([]);
|
||||||
const [selectedDocumentId, setSelectedDocumentId] = useState(null);
|
const [selectedDocumentId, setSelectedDocumentId] = useState(routeDocumentId);
|
||||||
const [documentDetails, setDocumentDetails] = useState(() => new Map());
|
const [documentDetails, setDocumentDetails] = useState(() => new Map());
|
||||||
const documentDetailsRef = useRef(documentDetails);
|
const documentDetailsRef = useRef(documentDetails);
|
||||||
const [searchResults, setSearchResults] = useState(null);
|
const [searchResults, setSearchResults] = useState(null);
|
||||||
@@ -720,6 +828,7 @@ function App() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
||||||
const [draggedDocumentId, setDraggedDocumentId] = useState(null);
|
const [draggedDocumentId, setDraggedDocumentId] = useState(null);
|
||||||
|
const [draggedFolderId, setDraggedFolderId] = useState(null);
|
||||||
const [dropOverlayState, setDropOverlayState] = useState({
|
const [dropOverlayState, setDropOverlayState] = useState({
|
||||||
active: false,
|
active: false,
|
||||||
folderName: DEFAULT_FOLDER_NAME,
|
folderName: DEFAULT_FOLDER_NAME,
|
||||||
@@ -836,6 +945,147 @@ function App() {
|
|||||||
[folderContents],
|
[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(
|
const ensureDocumentDetail = useCallback(
|
||||||
async (documentId, { force = false } = {}) => {
|
async (documentId, { force = false } = {}) => {
|
||||||
if (!documentId) return null;
|
if (!documentId) return null;
|
||||||
@@ -876,13 +1126,14 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [setStatusMessage]);
|
}, [setStatusMessage]);
|
||||||
|
|
||||||
const selectFolder = useCallback(
|
const loadFolder = useCallback(
|
||||||
async (folderId) => {
|
async (folderId, { showLoading = true } = {}) => {
|
||||||
setSelectedFolder(folderId);
|
const targetId = folderId || 'root';
|
||||||
setLoading(true);
|
setSelectedFolder(targetId);
|
||||||
|
if (showLoading) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const contents = await ensureFolderData(folderId, { force: true });
|
const contents = await ensureFolderData(targetId, { force: true });
|
||||||
const nextSelectedId = applySelectedFolder(folderId, contents);
|
const nextSelectedId = applySelectedFolder(targetId, contents);
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
if (nextSelectedId) {
|
if (nextSelectedId) {
|
||||||
await ensureDocumentDetail(nextSelectedId, { force: true });
|
await ensureDocumentDetail(nextSelectedId, { force: true });
|
||||||
@@ -891,12 +1142,27 @@ function App() {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
setStatusMessage('Failed to load folder contents.', 'error');
|
setStatusMessage('Failed to load folder contents.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (showLoading) setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[ensureFolderData, applySelectedFolder, ensureDocumentDetail, setStatusMessage],
|
[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 () => {
|
const initializeAfterLogin = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -914,6 +1180,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [refreshTags, ensureFolderData, applySelectedFolder, ensureDocumentDetail, setStatusMessage]);
|
}, [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 () => {
|
const refreshCurrentFolder = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -1229,13 +1510,17 @@ const handleDownload = useCallback(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const openDocumentPreview = useCallback(
|
const openDocumentPreview = useCallback(
|
||||||
async (documentId) => {
|
async (documentId, { replace = false, skipNavigate = false } = {}) => {
|
||||||
if (!documentId) return;
|
if (!documentId) return;
|
||||||
setPreviewDocumentId(documentId);
|
setPreviewDocumentId(documentId);
|
||||||
setPreviewDocumentLoading(true);
|
setPreviewDocumentLoading(true);
|
||||||
try {
|
try {
|
||||||
await ensureDocumentDetail(documentId, { force: false });
|
await ensureDocumentDetail(documentId, { force: false });
|
||||||
await ensurePreviewUrl(documentId, { force: false });
|
await ensurePreviewUrl(documentId, { force: false });
|
||||||
|
setSelectedDocumentId(documentId);
|
||||||
|
if (!skipNavigate && navigate) {
|
||||||
|
navigate(`/documents/${documentId}`, { replace });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setPreviewDocumentId(null);
|
setPreviewDocumentId(null);
|
||||||
@@ -1243,13 +1528,26 @@ const handleDownload = useCallback(
|
|||||||
setPreviewDocumentLoading(false);
|
setPreviewDocumentLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[ensureDocumentDetail, ensurePreviewUrl],
|
[ensureDocumentDetail, ensurePreviewUrl, navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeDocumentPreview = useCallback(() => {
|
const closeDocumentPreview = useCallback(
|
||||||
|
(folderId = null) => {
|
||||||
setPreviewDocumentId(null);
|
setPreviewDocumentId(null);
|
||||||
setPreviewDocumentLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!previewDocumentId) return;
|
if (!previewDocumentId) return;
|
||||||
@@ -1262,6 +1560,52 @@ const handleDownload = useCallback(
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [previewDocumentId, closeDocumentPreview]);
|
}, [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(
|
const handleTagRemove = useCallback(
|
||||||
async (documentId, tagId) => {
|
async (documentId, tagId) => {
|
||||||
try {
|
try {
|
||||||
@@ -1636,19 +1980,60 @@ const handleDownload = useCallback(
|
|||||||
onSelect: selectFolder,
|
onSelect: selectFolder,
|
||||||
onDrop: async (event, folderId) => {
|
onDrop: async (event, folderId) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
event.currentTarget.classList.remove('is-drop-target');
|
event.currentTarget.classList.remove('is-drop-target');
|
||||||
|
|
||||||
|
let folderSourceId = draggedFolderId;
|
||||||
|
if (!folderSourceId) {
|
||||||
try {
|
try {
|
||||||
await handleFileDrop(event.dataTransfer, folderId);
|
if (event.dataTransfer.types?.includes('application/x-paperless-folder')) {
|
||||||
} finally {
|
folderSourceId = event.dataTransfer.getData('application/x-paperless-folder');
|
||||||
// no-op; errors handled inside handleFileDrop
|
|
||||||
}
|
}
|
||||||
const documentId = event.dataTransfer.getData('application/x-paperless-doc') || draggedDocumentId;
|
} catch (
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
error
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
if (!documentId || folderId === selectedFolder) return;
|
||||||
moveDocumentToFolder(documentId, folderId);
|
moveDocumentToFolder(documentId, folderId);
|
||||||
},
|
},
|
||||||
onDragOver: (event) => {
|
onDragOver: (event, folderId) => {
|
||||||
if (hasFiles(event) || draggedDocumentId) {
|
const folderDragActive = Boolean(draggedFolderId);
|
||||||
|
if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFiles(event)) {
|
||||||
event.preventDefault();
|
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');
|
event.currentTarget.classList.add('is-drop-target');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1687,6 +2072,35 @@ const handleDownload = useCallback(
|
|||||||
? documentDetails.get(selectedDocument.id)
|
? documentDetails.get(selectedDocument.id)
|
||||||
: null;
|
: 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(() => {
|
useEffect(() => {
|
||||||
if (!selectedDocument) return;
|
if (!selectedDocument) return;
|
||||||
ensurePreviewUrl(selectedDocument.id).catch(() => {});
|
ensurePreviewUrl(selectedDocument.id).catch(() => {});
|
||||||
@@ -1733,7 +2147,7 @@ const handleDownload = useCallback(
|
|||||||
<span className="app-bar__hint">
|
<span className="app-bar__hint">
|
||||||
{previewActive
|
{previewActive
|
||||||
? 'Viewing document preview. Press ← Back to return to the library.'
|
? 'Viewing document preview. Press ← Back to return to the library.'
|
||||||
: `Drag files anywhere to upload into "${currentFolderName}".`}
|
: 'Drag files here to upload.'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="app-bar__actions">
|
<div className="app-bar__actions">
|
||||||
@@ -1779,9 +2193,13 @@ const handleDownload = useCallback(
|
|||||||
onDeleteFolder={handleFolderDelete}
|
onDeleteFolder={handleFolderDelete}
|
||||||
disableActions={loading}
|
disableActions={loading}
|
||||||
selectedFolder={selectedFolder}
|
selectedFolder={selectedFolder}
|
||||||
|
onFolderDragStart={handleFolderDragStart}
|
||||||
|
onFolderDragEnd={handleFolderDragEnd}
|
||||||
|
draggedFolderId={draggedFolderId}
|
||||||
/>
|
/>
|
||||||
<DocumentsTable
|
<DocumentsTable
|
||||||
currentFolderName={currentFolderName}
|
currentFolderName={currentFolderName}
|
||||||
|
breadcrumbs={breadcrumbs}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onRefresh={refreshCurrentFolder}
|
onRefresh={refreshCurrentFolder}
|
||||||
subfolders={currentSubfolders}
|
subfolders={currentSubfolders}
|
||||||
@@ -1792,6 +2210,9 @@ const handleDownload = useCallback(
|
|||||||
onFolderDrop={folderClickHandlers.onDrop}
|
onFolderDrop={folderClickHandlers.onDrop}
|
||||||
onFolderDragOver={folderClickHandlers.onDragOver}
|
onFolderDragOver={folderClickHandlers.onDragOver}
|
||||||
onFolderDragLeave={folderClickHandlers.onDragLeave}
|
onFolderDragLeave={folderClickHandlers.onDragLeave}
|
||||||
|
onFolderDragStart={handleFolderDragStart}
|
||||||
|
onFolderDragEnd={handleFolderDragEnd}
|
||||||
|
draggedFolderId={draggedFolderId}
|
||||||
onFolderDelete={handleFolderDelete}
|
onFolderDelete={handleFolderDelete}
|
||||||
onDocumentSelect={(docId) => {
|
onDocumentSelect={(docId) => {
|
||||||
setSelectedDocumentId(docId);
|
setSelectedDocumentId(docId);
|
||||||
@@ -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 container = document.getElementById('app');
|
||||||
const root = createRoot(container);
|
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);
|
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 {
|
.column-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -438,7 +477,6 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
.documents-panel thead th {
|
.documents-panel thead th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
background: var(--surface);
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding: 0.45rem 0.6rem;
|
padding: 0.45rem 0.6rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -465,14 +503,13 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 3px;
|
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 {
|
.thumb-placeholder {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: var(--surface-subtle);
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -482,6 +519,18 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
letter-spacing: 0.08em;
|
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 th.actions-column,
|
||||||
.documents-panel td.actions {
|
.documents-panel td.actions {
|
||||||
width: 1%;
|
width: 1%;
|
||||||
|
|||||||
Reference in New Issue
Block a user