4012 lines
121 KiB
React
4012 lines
121 KiB
React
import React, {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
useContext,
|
||
} from 'react';
|
||
import { createRoot } from 'react-dom/client';
|
||
import axios from 'axios';
|
||
import {
|
||
HashRouter,
|
||
Navigate,
|
||
Route,
|
||
Routes,
|
||
Outlet,
|
||
useLocation,
|
||
useMatch,
|
||
useNavigate,
|
||
matchPath,
|
||
} from 'react-router-dom';
|
||
import './styles.css';
|
||
|
||
const API_ROOT = (process.env.API_BASE_URL || '').replace(/\/$/, '');
|
||
|
||
const api = axios.create({
|
||
baseURL: API_ROOT ? `${API_ROOT}/api` : '/api',
|
||
withCredentials: true,
|
||
});
|
||
|
||
const STORED_TOKEN = window.localStorage.getItem('paperless_token') || '';
|
||
if (STORED_TOKEN) {
|
||
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
|
||
}
|
||
|
||
const DEFAULT_FOLDER_NAME = 'All Documents';
|
||
|
||
const resolveApiPath = (path = '') => (API_ROOT ? `${API_ROOT}${path}` : path);
|
||
|
||
const hasFiles = (event) =>
|
||
Array.from(event.dataTransfer?.types || []).includes('Files');
|
||
|
||
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
|
||
|
||
const hexToRgb = (input) => {
|
||
if (!input) return null;
|
||
const match = HEX_COLOR_PATTERN.exec(input.trim());
|
||
if (!match) return null;
|
||
const value = parseInt(match[1], 16);
|
||
return {
|
||
r: (value >> 16) & 0xff,
|
||
g: (value >> 8) & 0xff,
|
||
b: value & 0xff,
|
||
hex: `#${match[1].toLowerCase()}`,
|
||
};
|
||
};
|
||
|
||
const relativeLuminance = ({ r, g, b }) => {
|
||
const transform = (channel) => {
|
||
const normalized = channel / 255;
|
||
return normalized <= 0.03928
|
||
? normalized / 12.92
|
||
: ((normalized + 0.055) / 1.055) ** 2.4;
|
||
};
|
||
const [red, green, blue] = [transform(r), transform(g), transform(b)];
|
||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||
};
|
||
|
||
const getTagColorStyle = (hex) => {
|
||
const rgb = hexToRgb(hex);
|
||
if (!rgb) return null;
|
||
const luminance = relativeLuminance(rgb);
|
||
const textColor = luminance > 0.6 ? '#1f1f1f' : '#ffffff';
|
||
return {
|
||
backgroundColor: rgb.hex,
|
||
borderColor: rgb.hex,
|
||
color: textColor,
|
||
};
|
||
};
|
||
|
||
const IconChevronRight = ({ className }) => (
|
||
<svg
|
||
className={className ? `icon ${className}` : 'icon'}
|
||
viewBox="0 0 24 24"
|
||
role="img"
|
||
aria-hidden="true"
|
||
focusable="false"
|
||
>
|
||
<path
|
||
d="M9 6l6 6-6 6"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
);
|
||
|
||
const IconTrash = ({ className }) => (
|
||
<svg
|
||
className={className ? `icon ${className}` : 'icon'}
|
||
viewBox="0 0 24 24"
|
||
role="img"
|
||
aria-hidden="true"
|
||
focusable="false"
|
||
>
|
||
<path
|
||
d="M4 7h16"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path
|
||
d="M10 11v6m4-6v6"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path
|
||
d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path
|
||
d="M9 7V4h6v3"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
);
|
||
|
||
const IconDownload = ({ className }) => (
|
||
<svg
|
||
className={className ? `icon ${className}` : 'icon'}
|
||
viewBox="0 0 24 24"
|
||
role="img"
|
||
aria-hidden="true"
|
||
focusable="false"
|
||
>
|
||
<path
|
||
d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path
|
||
d="M7 11l5 5 5-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path
|
||
d="M12 5v11"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</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,
|
||
parentId: null,
|
||
children: [],
|
||
expanded: true,
|
||
loaded: false,
|
||
});
|
||
|
||
const StatusBanner = ({ status }) => {
|
||
if (!status) return null;
|
||
return <div className={`status-banner ${status.variant}`}>{status.message}</div>;
|
||
};
|
||
|
||
const DropOverlay = ({ active, folderName }) => (
|
||
<div className={`drop-overlay${active ? ' active' : ''}`}>
|
||
<div className="drop-overlay__content">
|
||
Drop files to upload to <strong>{folderName}</strong>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const LoginView = ({ onSubmit, status }) => (
|
||
<div className="login-screen">
|
||
<div className="login-card">
|
||
<h1>Paperless-NEO</h1>
|
||
<p>Authenticate to manage your documents.</p>
|
||
<form onSubmit={onSubmit}>
|
||
<label htmlFor="username">Username</label>
|
||
<input
|
||
id="username"
|
||
name="username"
|
||
placeholder="admin"
|
||
autoComplete="username"
|
||
required
|
||
/>
|
||
<label htmlFor="password">Password</label>
|
||
<input
|
||
id="password"
|
||
name="password"
|
||
type="password"
|
||
placeholder="••••••"
|
||
autoComplete="current-password"
|
||
required
|
||
/>
|
||
<button type="submit">Sign in</button>
|
||
</form>
|
||
<StatusBanner status={status} />
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const FolderNode = ({
|
||
node,
|
||
depth,
|
||
isSelected,
|
||
onToggle,
|
||
onSelect,
|
||
onDrop,
|
||
onDragOver,
|
||
onDragLeave,
|
||
onDelete,
|
||
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();
|
||
if (canToggle) {
|
||
onToggle(node.id);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<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' : ''}`}
|
||
onClick={handleToggleClick}
|
||
>
|
||
{icon}
|
||
</span>
|
||
<span className="name">{node.name}</span>
|
||
{node.id !== 'root' && (
|
||
<button
|
||
type="button"
|
||
className="icon-button ghost"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onDelete(node.id);
|
||
}}
|
||
title="Delete folder"
|
||
aria-label={`Delete folder ${node.name}`}
|
||
>
|
||
<IconTrash className="icon-trash" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
{node.expanded && node.children.length > 0 && (
|
||
<ul className="folder-children">
|
||
{renderChildren(node.children, depth + 1)}
|
||
</ul>
|
||
)}
|
||
</li>
|
||
);
|
||
};
|
||
|
||
const FilterBar = ({
|
||
query,
|
||
onQueryChange,
|
||
tags,
|
||
activeTagIds,
|
||
onToggleTag,
|
||
onClear,
|
||
hasFilters,
|
||
}) => (
|
||
<div className="filter-bar">
|
||
<input
|
||
type="search"
|
||
placeholder="Search documents"
|
||
value={query}
|
||
onChange={(event) => onQueryChange(event.target.value)}
|
||
/>
|
||
<div className="tag-filters">
|
||
{tags.length ? (
|
||
tags.map((tag) => {
|
||
const isActive = activeTagIds.includes(tag.id);
|
||
const style = getTagColorStyle(tag.color);
|
||
const buttonStyle = style
|
||
? {
|
||
...style,
|
||
opacity: isActive ? 1 : 0.95,
|
||
boxShadow: isActive ? '0 0 0 1px rgba(0, 0, 0, 0.18)' : undefined,
|
||
}
|
||
: undefined;
|
||
return (
|
||
<button
|
||
key={tag.id}
|
||
type="button"
|
||
className={`tag-filter${isActive ? ' active' : ''}`}
|
||
onClick={() => onToggleTag(tag.id)}
|
||
style={buttonStyle}
|
||
>
|
||
{tag.label}
|
||
</button>
|
||
);
|
||
})
|
||
) : (
|
||
<span className="meta">No tags yet</span>
|
||
)}
|
||
</div>
|
||
<div className="filter-actions">
|
||
{hasFilters && (
|
||
<button type="button" className="secondary" onClick={onClear}>
|
||
Clear
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const DocumentsTable = ({
|
||
currentFolderName,
|
||
breadcrumbs,
|
||
onRefresh,
|
||
subfolders,
|
||
documents,
|
||
searchResults,
|
||
isFilterActive,
|
||
onFolderSelect,
|
||
onFolderDrop,
|
||
onFolderDragOver,
|
||
onFolderDragLeave,
|
||
onFolderDragStart,
|
||
onFolderDragEnd,
|
||
draggedFolderId,
|
||
onFolderDelete,
|
||
onDocumentRowClick,
|
||
onDocumentOpen,
|
||
selectedDocumentIds,
|
||
focusedDocumentId,
|
||
draggingDocumentIds = [],
|
||
onDocumentDragStart,
|
||
onDocumentDragEnd,
|
||
onDocumentDelete,
|
||
filterBar,
|
||
tagLookupById,
|
||
}) => {
|
||
const showingSearchResults = searchResults !== null;
|
||
const rows = showingSearchResults ? searchResults : documents;
|
||
const selectedSet = useMemo(
|
||
() => new Set(selectedDocumentIds),
|
||
[selectedDocumentIds],
|
||
);
|
||
const draggingSet = useMemo(
|
||
() => new Set(draggingDocumentIds || []),
|
||
[draggingDocumentIds],
|
||
);
|
||
|
||
return (
|
||
<section className="documents-panel column">
|
||
<div className="column-header">
|
||
<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>
|
||
)}
|
||
</div>
|
||
<div className="header-actions">
|
||
<button className="secondary" onClick={onRefresh}>
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="column-body">
|
||
<div className="column-toolbar">{filterBar}</div>
|
||
<div className="documents-scroll">
|
||
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
|
||
<div className="empty-state">
|
||
Drop files anywhere or onto a folder to upload documents.
|
||
</div>
|
||
) : (
|
||
<table aria-multiselectable="true">
|
||
<thead>
|
||
<tr>
|
||
<th className="thumb-column">Preview</th>
|
||
<th>Name</th>
|
||
<th>Type</th>
|
||
<th>Updated</th>
|
||
<th className="actions-column">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{!showingSearchResults &&
|
||
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 icon">
|
||
<IconFolder className="icon-inline" />
|
||
</div>
|
||
</td>
|
||
<td>{folder.name}</td>
|
||
<td>Folder</td>
|
||
<td>—</td>
|
||
<td className="actions">
|
||
<button
|
||
type="button"
|
||
className="icon-button danger"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onFolderDelete(folder.id);
|
||
}}
|
||
>
|
||
Delete
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{rows.map((doc) => {
|
||
const isSelected = selectedSet.has(doc.id);
|
||
const rowClasses = ['document'];
|
||
if (isSelected) rowClasses.push('selected');
|
||
if (focusedDocumentId === doc.id) rowClasses.push('focused');
|
||
if (draggingSet.has(doc.id)) {
|
||
rowClasses.push('dragging');
|
||
}
|
||
|
||
return (
|
||
<tr
|
||
key={doc.id}
|
||
className={rowClasses.join(' ')}
|
||
aria-selected={isSelected}
|
||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||
onDoubleClick={(event) => {
|
||
event.stopPropagation();
|
||
if (onDocumentOpen) {
|
||
onDocumentOpen(doc.id);
|
||
}
|
||
}}
|
||
draggable
|
||
onDragStart={(event) => onDocumentDragStart(event, doc.id)}
|
||
onDragEnd={onDocumentDragEnd}
|
||
>
|
||
<td className="thumb-cell">
|
||
{doc.thumbnail ? (
|
||
<img
|
||
src={doc.thumbnail.url}
|
||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||
className="document-thumbnail"
|
||
/>
|
||
) : (
|
||
<div className="thumb-placeholder">DOC</div>
|
||
)}
|
||
</td>
|
||
<td>
|
||
<div className="doc-name">
|
||
<span className="doc-name__title">{doc.title || doc.original_name}</span>
|
||
{(doc.tags || []).length > 0 && (
|
||
<div className="doc-name__tags">
|
||
{(doc.tags || []).map((tag) => {
|
||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||
const style = getTagColorStyle(colorSource);
|
||
return (
|
||
<span
|
||
key={tag.id}
|
||
className="badge tag-chip"
|
||
style={style || undefined}
|
||
title={tag.label}
|
||
>
|
||
{tag.label}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td>{doc.content_type || 'Document'}</td>
|
||
<td>{
|
||
doc.updated_at
|
||
? new Date(doc.updated_at).toLocaleString()
|
||
: '—'
|
||
}</td>
|
||
<td className="actions">
|
||
<div className="action-buttons">
|
||
{doc.download_path ? (
|
||
<a
|
||
className="button-link with-icon"
|
||
href={resolveApiPath(doc.download_path)}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
onClick={(event) => event.stopPropagation()}
|
||
onAuxClick={(event) => event.stopPropagation()}
|
||
onContextMenu={(event) => event.stopPropagation()}
|
||
>
|
||
<IconDownload className="icon-inline" />
|
||
<span>Download</span>
|
||
</a>
|
||
) : (
|
||
<span className="meta">No download</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="danger"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onDocumentDelete?.(doc.id);
|
||
}}
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
{rows.length === 0 && isFilterActive && (
|
||
<div className="empty-state">No documents match the current filters.</div>
|
||
)}
|
||
{showingSearchResults && rows.length > 0 && (
|
||
<div className="search-hint">
|
||
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|
||
|
||
const computeStackAngle = (docId, index) => {
|
||
if (index === 0) return 0;
|
||
let hash = 0;
|
||
const source = docId || `stack-${index}`;
|
||
for (let i = 0; i < source.length; i += 1) {
|
||
hash = (hash * 31 + source.charCodeAt(i)) % 997;
|
||
}
|
||
const magnitude = Math.max(3, (hash % 13) + 3); // 3..15
|
||
const sign = index % 2 === 0 ? 1 : -1;
|
||
return magnitude * sign;
|
||
};
|
||
|
||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||
|
||
const PreviewStack = ({
|
||
items = [],
|
||
maxItems = MAX_PREVIEW_STACK_ITEMS,
|
||
emptyMessage = 'Preview unavailable',
|
||
onItemActivate,
|
||
onOpenPreview,
|
||
activeItemId = null,
|
||
}) => {
|
||
if (!items.length) {
|
||
return <span className="meta">{emptyMessage}</span>;
|
||
}
|
||
|
||
const limited = items.slice(0, maxItems);
|
||
const hasMultiple = limited.length > 1;
|
||
const preparedItems = useMemo(
|
||
() =>
|
||
limited.map((entry, index) => ({
|
||
entry,
|
||
angle: index === 0 ? 0 : computeStackAngle(entry.id, index),
|
||
offset: 0,
|
||
})),
|
||
[limited],
|
||
);
|
||
|
||
return (
|
||
<div className="preview-stack preview-stack--stacked">
|
||
{preparedItems.map(({ entry, angle, offset }, index) => {
|
||
const transform = hasMultiple
|
||
? `translate(-50%, -50%) rotate(${angle}deg)`
|
||
: 'translate(-50%, -50%)';
|
||
const isFront = index === 0;
|
||
return (
|
||
<div
|
||
key={entry.id || index}
|
||
className={`preview-stack__item orientation-${entry.orientation || 'landscape'}`}
|
||
style={{
|
||
zIndex: preparedItems.length - index,
|
||
transform,
|
||
}}
|
||
aria-hidden={
|
||
hasMultiple && !onItemActivate && !onOpenPreview ? 'true' : undefined
|
||
}
|
||
>
|
||
<img
|
||
src={entry.url}
|
||
alt={entry.alt || ''}
|
||
className="preview-stack__image"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
if (isFront && onOpenPreview) {
|
||
onOpenPreview(entry.id);
|
||
} else if (onItemActivate) {
|
||
onItemActivate(entry.id);
|
||
}
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (!onItemActivate && !onOpenPreview) return;
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (isFront && onOpenPreview) {
|
||
onOpenPreview(entry.id);
|
||
} else {
|
||
onItemActivate?.(entry.id);
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const DetailPanel = ({
|
||
selectedDocuments = [],
|
||
detailMap = new Map(),
|
||
tags = [],
|
||
tagLookupById = new Map(),
|
||
tagLookupByLabel = new Map(),
|
||
onTagAdd,
|
||
onTagRemove,
|
||
onRegenerateThumbnails,
|
||
previewEntry,
|
||
onOpenPreview,
|
||
onBulkTagAdd,
|
||
onBulkTagRemove,
|
||
onBulkMove,
|
||
onBulkReanalyze,
|
||
folderOptions = [],
|
||
defaultMoveTarget = 'root',
|
||
onPromoteSelection,
|
||
activePreviewId = null,
|
||
}) => {
|
||
const lookup = detailMap && typeof detailMap.get === 'function' ? detailMap : new Map();
|
||
const selectedCount = selectedDocuments.length;
|
||
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
||
const detail = singleDoc ? lookup.get(singleDoc.id) || null : null;
|
||
|
||
const handlePreviewActivate = useCallback(
|
||
(docId) => {
|
||
if (!docId) return;
|
||
onPromoteSelection?.(docId);
|
||
},
|
||
[onPromoteSelection],
|
||
);
|
||
|
||
const makePreviewItem = useCallback((doc, detailEntry, fallbackUrl = null) => {
|
||
if (!doc) return null;
|
||
const url = doc.thumbnail?.url || detailEntry?.document?.thumbnail?.url || fallbackUrl;
|
||
if (!url) return null;
|
||
const asset = detailEntry?.assets?.find((item) => item.asset_type === 'thumbnail');
|
||
const width = asset?.width || 0;
|
||
const height = asset?.height || 0;
|
||
const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape';
|
||
return {
|
||
id: doc.id,
|
||
url,
|
||
orientation,
|
||
alt: doc.title || doc.original_name || 'Document preview',
|
||
};
|
||
}, []);
|
||
|
||
const stackDocuments = useMemo(() => {
|
||
if (!selectedDocuments.length) return [];
|
||
const seen = new Set();
|
||
const ordered = [];
|
||
for (let index = selectedDocuments.length - 1; index >= 0; index -= 1) {
|
||
const doc = selectedDocuments[index];
|
||
if (!doc?.id || seen.has(doc.id)) continue;
|
||
seen.add(doc.id);
|
||
ordered.push(doc);
|
||
if (ordered.length >= MAX_PREVIEW_STACK_ITEMS) {
|
||
break;
|
||
}
|
||
}
|
||
return ordered;
|
||
}, [selectedDocuments]);
|
||
|
||
const singlePreviewItems = useMemo(() => {
|
||
if (!singleDoc) return [];
|
||
const item = makePreviewItem(singleDoc, detail, previewEntry?.url || null);
|
||
return item ? [item] : [];
|
||
}, [singleDoc, detail, previewEntry, makePreviewItem]);
|
||
|
||
const stackPreviews = useMemo(
|
||
() =>
|
||
stackDocuments
|
||
.map((doc) => makePreviewItem(doc, lookup.get(doc.id)))
|
||
.filter(Boolean),
|
||
[stackDocuments, lookup, makePreviewItem],
|
||
);
|
||
|
||
const commonTags = useMemo(() => {
|
||
if (selectedCount < 2) return [];
|
||
const tagSets = selectedDocuments.map((doc) => new Set((doc.tags || []).map((tag) => tag.label)));
|
||
if (!tagSets.length) return [];
|
||
const intersection = new Set(tagSets[0]);
|
||
tagSets.slice(1).forEach((set) => {
|
||
[...intersection].forEach((label) => {
|
||
if (!set.has(label)) {
|
||
intersection.delete(label);
|
||
}
|
||
});
|
||
});
|
||
return [...intersection];
|
||
}, [selectedDocuments, selectedCount]);
|
||
|
||
const stackTotalSizeBytes = useMemo(() => {
|
||
if (!stackPreviews.length) return 0;
|
||
return stackPreviews.reduce((sum, item) => {
|
||
const detailEntry = lookup.get(item.id);
|
||
const bytes = detailEntry?.current_version?.size_bytes || 0;
|
||
return sum + (typeof bytes === 'number' ? bytes : 0);
|
||
}, 0);
|
||
}, [stackPreviews, lookup]);
|
||
|
||
const renderSingle = () => {
|
||
if (!singleDoc) {
|
||
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
||
}
|
||
if (!detail) {
|
||
return <p className="meta">Loading details…</p>;
|
||
}
|
||
|
||
const displayName = singleDoc.title || singleDoc.original_name;
|
||
const downloadHref = singleDoc.download_path
|
||
? resolveApiPath(singleDoc.download_path)
|
||
: null;
|
||
|
||
return (
|
||
<>
|
||
<div>
|
||
<div className="preview-pane preview-pane--stack">
|
||
<PreviewStack
|
||
items={singlePreviewItems}
|
||
maxItems={1}
|
||
emptyMessage="Preview loading…"
|
||
onItemActivate={handlePreviewActivate}
|
||
onOpenPreview={onOpenPreview}
|
||
activeItemId={activePreviewId}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<h3 style={{ margin: 0 }}>{displayName}</h3>
|
||
<div className="meta">
|
||
<div>
|
||
<strong>Uploaded:</strong>{' '}
|
||
{singleDoc.uploaded_at ? new Date(singleDoc.uploaded_at).toLocaleString() : '—'}
|
||
</div>
|
||
<div>
|
||
<strong>Size:</strong>{' '}
|
||
{detail.current_version
|
||
? `${(detail.current_version.size_bytes / 1024).toFixed(1)} KB`
|
||
: '—'}
|
||
</div>
|
||
<div>
|
||
<strong>Type:</strong> {singleDoc.content_type || 'Unknown'}
|
||
</div>
|
||
<div>
|
||
<strong>Issued:</strong>{' '}
|
||
{detail.document.issued_at
|
||
? new Date(detail.document.issued_at).toLocaleString()
|
||
: '—'}
|
||
</div>
|
||
<div>
|
||
<strong>Original filename:</strong>{' '}
|
||
{singleDoc.original_name}
|
||
</div>
|
||
</div>
|
||
<div className="detail-actions">
|
||
<a
|
||
className="button-link with-icon"
|
||
href={downloadHref || '#'}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
aria-disabled={!downloadHref}
|
||
onClick={(event) => {
|
||
if (!downloadHref) {
|
||
event.preventDefault();
|
||
}
|
||
}}
|
||
>
|
||
<IconDownload className="icon-inline" />
|
||
<span>Download</span>
|
||
</a>
|
||
<button
|
||
type="button"
|
||
className="secondary"
|
||
onClick={() => onOpenPreview(singleDoc.id)}
|
||
>
|
||
Open preview
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="secondary"
|
||
onClick={() => onRegenerateThumbnails(singleDoc.id)}
|
||
>
|
||
Re-run analysis
|
||
</button>
|
||
</div>
|
||
<div>
|
||
<dt>Tags</dt>
|
||
<div className="tag-list">
|
||
{detail.document.tags?.length ? (
|
||
detail.document.tags.map((tag) => {
|
||
const colorSource = tag?.color || tagLookupById.get(tag.id)?.color;
|
||
const style = getTagColorStyle(colorSource);
|
||
return (
|
||
<span key={tag.id} className="tag-pill" style={style || undefined}>
|
||
{tag.label}{' '}
|
||
<button type="button" onClick={() => onTagRemove(singleDoc.id, tag.id)}>
|
||
×
|
||
</button>
|
||
</span>
|
||
);
|
||
})
|
||
) : (
|
||
<span className="meta">No tags yet.</span>
|
||
)}
|
||
</div>
|
||
<form
|
||
className="inline"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
const input = event.currentTarget.elements.tag;
|
||
const value = input.value.trim();
|
||
if (!value) return;
|
||
onTagAdd(singleDoc, value, input);
|
||
}}
|
||
>
|
||
<input name="tag" placeholder="Add or create tag" list="tag-catalog" />
|
||
<button type="submit">Add</button>
|
||
<datalist id="tag-catalog">
|
||
{tags.map((tag) => (
|
||
<option key={tag.id} value={tag.label} />
|
||
))}
|
||
</datalist>
|
||
</form>
|
||
</div>
|
||
{detail.document.metadata &&
|
||
Object.keys(detail.document.metadata).length > 0 && (
|
||
<div>
|
||
<dt>Metadata</dt>
|
||
<pre>{JSON.stringify(detail.document.metadata, null, 2)}</pre>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
};
|
||
|
||
const renderBulk = () => {
|
||
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||
const sizeLabel = stackTotalSizeBytes
|
||
? `${(stackTotalSizeBytes / 1024).toFixed(1)} KB`
|
||
: '—';
|
||
|
||
return (
|
||
<>
|
||
<div>
|
||
<div className="preview-pane preview-pane--stack">
|
||
<PreviewStack
|
||
items={stackPreviews}
|
||
emptyMessage="No previews available."
|
||
onItemActivate={handlePreviewActivate}
|
||
onOpenPreview={onOpenPreview}
|
||
activeItemId={activePreviewId}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<h3 style={{ margin: 0 }}>{countLabel}</h3>
|
||
<div className="meta">
|
||
<div>
|
||
<strong>Total size (stack):</strong> {sizeLabel}
|
||
</div>
|
||
<div>
|
||
<strong>Common tags:</strong>{' '}
|
||
{commonTags.length ? commonTags.join(', ') : 'None'}
|
||
</div>
|
||
</div>
|
||
{commonTags.length > 0 && (
|
||
<div className="bulk-tags">
|
||
{commonTags.map((label) => {
|
||
const key = typeof label === 'string' ? label.toLowerCase() : '';
|
||
const tagInfo = key ? tagLookupByLabel.get(key) : null;
|
||
const style = getTagColorStyle(tagInfo?.color);
|
||
return (
|
||
<span key={label} className="tag-pill" style={style || undefined}>
|
||
{label}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
<div className="bulk-detail-actions">
|
||
<form
|
||
className="inline"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
const input = event.currentTarget.elements.tag;
|
||
const value = input.value.trim();
|
||
if (!value) return;
|
||
onBulkTagAdd?.({ label: value, input });
|
||
}}
|
||
>
|
||
<input name="tag" placeholder="Add tag to selection" list="tag-catalog" />
|
||
<button type="submit">Add tag</button>
|
||
</form>
|
||
<form
|
||
className="inline"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
const input = event.currentTarget.elements.tag;
|
||
const value = input.value.trim();
|
||
if (!value) return;
|
||
onBulkTagRemove?.({ label: value, input });
|
||
}}
|
||
>
|
||
<input name="tag" placeholder="Remove tag from selection" list="tag-catalog" />
|
||
<button type="submit" className="secondary">
|
||
Remove tag
|
||
</button>
|
||
</form>
|
||
</div>
|
||
<datalist id="tag-catalog">
|
||
{tags.map((tag) => (
|
||
<option key={tag.id} value={tag.label} />
|
||
))}
|
||
</datalist>
|
||
<div className="bulk-move">
|
||
<label htmlFor="detail-bulk-move" className="meta">
|
||
Move selection to folder
|
||
</label>
|
||
<select
|
||
id="detail-bulk-move"
|
||
name="target"
|
||
defaultValue={defaultMoveTarget || 'root'}
|
||
onChange={(event) => onBulkMove?.({ target: event.target.value })}
|
||
>
|
||
{folderOptions.map((option) => (
|
||
<option key={option.id} value={option.id}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="secondary"
|
||
onClick={() => onBulkReanalyze?.()}
|
||
>
|
||
Re-analyze selection
|
||
</button>
|
||
</>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<aside className="detail-panel column">
|
||
<div className="column-header">
|
||
<h2>Details</h2>
|
||
</div>
|
||
<div className="column-body scrollable">
|
||
{selectedCount <= 1 ? renderSingle() : renderBulk()}
|
||
</div>
|
||
</aside>
|
||
);
|
||
};
|
||
|
||
const PreviewWorkspace = ({
|
||
document,
|
||
detail,
|
||
previewEntry,
|
||
onClose,
|
||
onRegenerateThumbnails,
|
||
}) => {
|
||
if (!document) {
|
||
return null;
|
||
}
|
||
|
||
const title =
|
||
detail?.document?.title ||
|
||
document.title ||
|
||
detail?.document?.original_name ||
|
||
document.original_name;
|
||
const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
|
||
const downloadHref = document.download_path
|
||
? resolveApiPath(document.download_path)
|
||
: null;
|
||
|
||
return (
|
||
<section className="preview-workspace">
|
||
<header className="preview-workspace__header">
|
||
<div className="preview-workspace__meta">
|
||
<button
|
||
type="button"
|
||
className="secondary"
|
||
onClick={() => onClose(detail?.document?.folder_id ?? document.folder_id ?? 'root')}
|
||
>
|
||
← Back
|
||
</button>
|
||
<div>
|
||
<h2>{title}</h2>
|
||
<span className="meta">
|
||
{document.content_type || 'Document'}
|
||
{detail?.current_version?.size_bytes
|
||
? ` · ${(detail.current_version.size_bytes / 1024 / 1024).toFixed(2)} MB`
|
||
: ''}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="preview-workspace__actions">
|
||
<a
|
||
className="button-link with-icon"
|
||
href={downloadHref || '#'}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
aria-disabled={!downloadHref}
|
||
onClick={(event) => {
|
||
if (!downloadHref) {
|
||
event.preventDefault();
|
||
}
|
||
}}
|
||
>
|
||
<IconDownload className="icon-inline" />
|
||
<span>Download</span>
|
||
</a>
|
||
<button
|
||
type="button"
|
||
className="secondary"
|
||
onClick={() => onRegenerateThumbnails(document.id)}
|
||
>
|
||
Re-run analysis
|
||
</button>
|
||
</div>
|
||
</header>
|
||
<div className="preview-workspace__body">
|
||
{!previewEntry?.url ? (
|
||
<div className="preview-workspace__message">Loading preview…</div>
|
||
) : (
|
||
<iframe
|
||
src={previewEntry.url}
|
||
title={`Preview of ${title}`}
|
||
className="preview-workspace__object"
|
||
/>
|
||
)}
|
||
</div>
|
||
{detail?.document?.metadata && Object.keys(detail.document.metadata).length > 0 && (
|
||
<section className="preview-workspace__metadata">
|
||
<h3>Metadata</h3>
|
||
<pre>{JSON.stringify(detail.document.metadata, null, 2)}</pre>
|
||
</section>
|
||
)}
|
||
</section>
|
||
);
|
||
};
|
||
|
||
const Sidebar = ({
|
||
folderNodes,
|
||
onToggle,
|
||
onSelect,
|
||
onDrop,
|
||
onDragOver,
|
||
onDragLeave,
|
||
onCreateFolder,
|
||
onDeleteFolder,
|
||
selectedFolder,
|
||
onFolderDragStart,
|
||
onFolderDragEnd,
|
||
draggedFolderId,
|
||
onShowTags,
|
||
tags = [],
|
||
}) => {
|
||
const handleShowTags = onShowTags || (() => {});
|
||
const tagsRouteMatch = useMatch('/tags');
|
||
const isTagsRoute = Boolean(tagsRouteMatch);
|
||
|
||
const renderNodes = useCallback(
|
||
(ids, depth) =>
|
||
ids.map((id) => {
|
||
const node = folderNodes.get(id);
|
||
if (!node) return null;
|
||
return (
|
||
<FolderNode
|
||
key={id}
|
||
node={node}
|
||
depth={depth}
|
||
isSelected={selectedFolder === id}
|
||
onToggle={() => onToggle(id)}
|
||
onSelect={onSelect}
|
||
onDrop={onDrop}
|
||
onDragOver={onDragOver}
|
||
onDragLeave={onDragLeave}
|
||
onDelete={onDeleteFolder}
|
||
renderChildren={renderNodes}
|
||
onFolderDragStart={onFolderDragStart}
|
||
onFolderDragEnd={onFolderDragEnd}
|
||
draggingFolderId={draggedFolderId}
|
||
/>
|
||
);
|
||
}),
|
||
[
|
||
folderNodes,
|
||
selectedFolder,
|
||
onToggle,
|
||
onSelect,
|
||
onDrop,
|
||
onDragOver,
|
||
onDragLeave,
|
||
onDeleteFolder,
|
||
onFolderDragStart,
|
||
onFolderDragEnd,
|
||
draggedFolderId,
|
||
],
|
||
);
|
||
|
||
const rootNode = folderNodes.get('root');
|
||
|
||
return (
|
||
<aside className="sidebar column">
|
||
<div className="column-header">
|
||
<h2>Folders</h2>
|
||
</div>
|
||
<div className="column-body">
|
||
<form
|
||
className="new-folder-form inline"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
const input = event.currentTarget.elements['folder-name'];
|
||
const value = input.value.trim();
|
||
if (!value) return;
|
||
onCreateFolder(value, () => {
|
||
input.value = '';
|
||
});
|
||
}}
|
||
>
|
||
<input name="folder-name" placeholder="New folder name" required />
|
||
<button type="submit">Create</button>
|
||
</form>
|
||
<div className="folder-tree-scroller">
|
||
<ul className="folder-tree">
|
||
{rootNode && renderNodes([rootNode.id], 0)}
|
||
</ul>
|
||
</div>
|
||
<div className="sidebar-section">
|
||
<div className="sidebar-section__header">
|
||
<h3>Tags</h3>
|
||
<span className="meta">{tags.length}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`sidebar-link${isTagsRoute ? ' active' : ''}`}
|
||
onClick={handleShowTags}
|
||
>
|
||
All tags
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
);
|
||
};
|
||
|
||
const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
|
||
const [editingId, setEditingId] = useState(null);
|
||
const [draftLabel, setDraftLabel] = useState('');
|
||
const [draftColor, setDraftColor] = useState('');
|
||
const [error, setError] = useState(null);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
const startEdit = useCallback((tag) => {
|
||
setEditingId(tag.id);
|
||
setDraftLabel(tag.label || '');
|
||
setDraftColor(tag.color || '');
|
||
setError(null);
|
||
}, []);
|
||
|
||
const cancelEdit = useCallback(() => {
|
||
setEditingId(null);
|
||
setDraftLabel('');
|
||
setDraftColor('');
|
||
setSaving(false);
|
||
setError(null);
|
||
}, []);
|
||
|
||
const colorPickerValue = useMemo(() => {
|
||
if (!draftColor) {
|
||
return '#3366ff';
|
||
}
|
||
const match = HEX_COLOR_PATTERN.exec(draftColor.trim());
|
||
if (!match) {
|
||
return '#3366ff';
|
||
}
|
||
return `#${match[1].toLowerCase()}`;
|
||
}, [draftColor]);
|
||
|
||
const handleSave = useCallback(async () => {
|
||
if (!editingId) return;
|
||
|
||
const trimmedLabel = draftLabel.trim();
|
||
if (!trimmedLabel) {
|
||
setError('Tag label cannot be empty.');
|
||
return;
|
||
}
|
||
|
||
const trimmedColor = draftColor.trim();
|
||
const colorPattern = /^#([0-9a-fA-F]{6})$/;
|
||
if (trimmedColor && !colorPattern.test(trimmedColor)) {
|
||
setError('Colors must use the #RRGGBB format.');
|
||
return;
|
||
}
|
||
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
await onUpdateTag(editingId, {
|
||
label: trimmedLabel,
|
||
color: trimmedColor ? trimmedColor : null,
|
||
});
|
||
cancelEdit();
|
||
} catch (updateError) {
|
||
const message = updateError?.message || 'Failed to update tag.';
|
||
setError(message);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}, [editingId, draftLabel, draftColor, onUpdateTag, cancelEdit]);
|
||
|
||
const handleKeyDown = useCallback(
|
||
(event) => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
handleSave();
|
||
} else if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
cancelEdit();
|
||
}
|
||
},
|
||
[handleSave, cancelEdit],
|
||
);
|
||
|
||
return (
|
||
<section className="tags-panel column">
|
||
<div className="column-header">
|
||
<div className="column-header__titles">
|
||
<h2>Tags</h2>
|
||
<div className="column-subtitle">{tags.length} total</div>
|
||
</div>
|
||
<div className="header-actions">
|
||
<button className="secondary" type="button" onClick={onRefresh} disabled={saving}>
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="column-body tags-panel__body">
|
||
{error && (
|
||
<div className="tags-panel__error" role="alert">
|
||
{error}
|
||
</div>
|
||
)}
|
||
{tags.length === 0 ? (
|
||
<div className="empty-state">No tags created yet.</div>
|
||
) : (
|
||
<div className="tags-table">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">Tag</th>
|
||
<th scope="col">Color</th>
|
||
<th scope="col" className="numeric">
|
||
Documents
|
||
</th>
|
||
<th scope="col" className="actions">
|
||
Actions
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{tags.map((tag) => {
|
||
const isEditing = editingId === tag.id;
|
||
return (
|
||
<tr key={tag.id} className={isEditing ? 'editing' : ''}>
|
||
<td className="tags-table__label">
|
||
{isEditing ? (
|
||
<input
|
||
className="tags-table__label-input"
|
||
value={draftLabel}
|
||
onChange={(event) => setDraftLabel(event.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
disabled={saving}
|
||
autoFocus
|
||
/>
|
||
) : (
|
||
<span
|
||
className="badge tag-chip"
|
||
style={getTagColorStyle(tag.color) || undefined}
|
||
>
|
||
{tag.label}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td>
|
||
{isEditing ? (
|
||
<div className="tags-table__color-editor">
|
||
<input
|
||
type="color"
|
||
className="tags-table__color-picker"
|
||
value={colorPickerValue}
|
||
onChange={(event) => setDraftColor(event.target.value)}
|
||
disabled={saving}
|
||
aria-label="Pick tag color"
|
||
/>
|
||
{draftColor && (
|
||
<button
|
||
type="button"
|
||
className="secondary"
|
||
onClick={() => setDraftColor('')}
|
||
disabled={saving}
|
||
>
|
||
Clear
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : tag.color ? (
|
||
<span
|
||
className="tags-table__swatch"
|
||
style={{ backgroundColor: tag.color }}
|
||
aria-label={`Tag color ${tag.color}`}
|
||
/>
|
||
) : (
|
||
<span className="meta">—</span>
|
||
)}
|
||
</td>
|
||
<td className="numeric">{tag.usage_count ?? 0}</td>
|
||
<td className="actions">
|
||
{isEditing ? (
|
||
<div className="tags-table__edit-controls">
|
||
<button
|
||
type="button"
|
||
className="secondary"
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
>
|
||
Save
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="secondary"
|
||
onClick={cancelEdit}
|
||
disabled={saving}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="secondary"
|
||
onClick={() => startEdit(tag)}
|
||
>
|
||
Edit
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|
||
|
||
const MainLayout = ({ sidebarProps, children, className = 'app-main' }) => (
|
||
<main className={className}>
|
||
<Sidebar {...sidebarProps} />
|
||
{children}
|
||
</main>
|
||
);
|
||
|
||
const AppShellContext = React.createContext(null);
|
||
|
||
const AppLayout = () => {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const folderMatch = matchPath('/folders/:folderId', location.pathname);
|
||
const nestedDocMatch = matchPath('/folders/:folderId/documents/:documentId', location.pathname);
|
||
const docMatch = nestedDocMatch || matchPath('/documents/:documentId', location.pathname);
|
||
const routeFolderId =
|
||
folderMatch?.params?.folderId || nestedDocMatch?.params?.folderId || null;
|
||
const routeDocumentId = docMatch?.params?.documentId || null;
|
||
const [token, setToken] = useState(() => window.localStorage.getItem('paperless_token') || '');
|
||
const [status, setStatus] = useState(null);
|
||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||
setStatus(message ? { message, variant } : null);
|
||
}, []);
|
||
const [loading, setLoading] = useState(false);
|
||
const [folderNodes, setFolderNodes] = useState(() => {
|
||
const rootNode = createRootNode();
|
||
return new Map([[rootNode.id, rootNode]]);
|
||
});
|
||
const [folderContents, setFolderContents] = useState(() => new Map());
|
||
const [selectedFolder, setSelectedFolder] = useState(routeFolderId || 'root');
|
||
const [currentFolder, setCurrentFolder] = useState(null);
|
||
const [currentSubfolders, setCurrentSubfolders] = useState([]);
|
||
const [documents, setDocuments] = useState([]);
|
||
const initialSelection = routeDocumentId ? [routeDocumentId] : [];
|
||
const [selectedDocumentIds, setSelectedDocumentIds] = useState(initialSelection);
|
||
const [selectionOrder, setSelectionOrder] = useState(initialSelection);
|
||
const [focusedDocumentId, setFocusedDocumentId] = useState(routeDocumentId);
|
||
const [documentDetails, setDocumentDetails] = useState(() => new Map());
|
||
const documentDetailsRef = useRef(documentDetails);
|
||
const tokenRef = useRef(token);
|
||
const refreshPromiseRef = useRef(null);
|
||
const breadcrumbFetchRef = useRef(new Set());
|
||
const refreshAccessToken = useCallback(async () => {
|
||
console.log('[Auth] Attempting to refresh access token…');
|
||
try {
|
||
const { data } = await api.post('/auth/refresh');
|
||
if (data?.access_token) {
|
||
setToken(data.access_token);
|
||
console.log('[Auth] Access token refreshed');
|
||
return data.access_token;
|
||
}
|
||
throw new Error('Missing access token in refresh response');
|
||
} catch (error) {
|
||
console.warn('[Auth] Failed to refresh access token', error);
|
||
throw error;
|
||
}
|
||
}, []);
|
||
const [searchResults, setSearchResults] = useState(null);
|
||
const [tags, setTags] = useState([]);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
||
const [draggedDocumentIds, setDraggedDocumentIds] = useState([]);
|
||
const [draggedFolderId, setDraggedFolderId] = useState(null);
|
||
const [dropOverlayState, setDropOverlayState] = useState({
|
||
active: false,
|
||
folderName: DEFAULT_FOLDER_NAME,
|
||
});
|
||
const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null);
|
||
const [previewDocumentId, setPreviewDocumentId] = useState(null);
|
||
const [previewDocumentLoading, setPreviewDocumentLoading] = useState(false);
|
||
|
||
const initializedRef = useRef(false);
|
||
const dragCounterRef = useRef(0);
|
||
const selectionAnchorRef = useRef(routeDocumentId);
|
||
const selectionOrderRef = useRef(initialSelection);
|
||
|
||
const tagLookupById = useMemo(() => {
|
||
const map = new Map();
|
||
tags.forEach((tag) => {
|
||
if (tag?.id) {
|
||
map.set(tag.id, tag);
|
||
}
|
||
});
|
||
return map;
|
||
}, [tags]);
|
||
|
||
const tagLookupByLabel = useMemo(() => {
|
||
const map = new Map();
|
||
tags.forEach((tag) => {
|
||
if (tag?.label) {
|
||
map.set(tag.label.toLowerCase(), tag);
|
||
}
|
||
});
|
||
return map;
|
||
}, [tags]);
|
||
|
||
const updateSelectionOrder = useCallback((nextSelection, interactedIds = []) => {
|
||
const nextSet = new Set(nextSelection);
|
||
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
|
||
const interacted = (interactedIds || []).filter((id, index, array) => array.indexOf(id) === index);
|
||
|
||
const base = previousOrder.filter((id) => !interacted.includes(id));
|
||
const result = [...base];
|
||
|
||
interacted.forEach((id) => {
|
||
if (nextSet.has(id) && !result.includes(id)) {
|
||
result.push(id);
|
||
}
|
||
});
|
||
|
||
nextSelection.forEach((id) => {
|
||
if (!result.includes(id)) {
|
||
result.push(id);
|
||
}
|
||
});
|
||
|
||
if (
|
||
result.length !== selectionOrderRef.current.length ||
|
||
result.some((id, index) => selectionOrderRef.current[index] !== id)
|
||
) {
|
||
selectionOrderRef.current = result;
|
||
setSelectionOrder(result);
|
||
} else {
|
||
selectionOrderRef.current = result;
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
documentDetailsRef.current = documentDetails;
|
||
}, [documentDetails]);
|
||
|
||
useEffect(() => {
|
||
tokenRef.current = token;
|
||
}, [token]);
|
||
|
||
useEffect(() => {
|
||
if (token) {
|
||
api.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||
window.localStorage.setItem('paperless_token', token);
|
||
} else {
|
||
delete api.defaults.headers.common.Authorization;
|
||
window.localStorage.removeItem('paperless_token');
|
||
}
|
||
}, [token]);
|
||
|
||
useEffect(() => {
|
||
const requestInterceptor = api.interceptors.request.use((config) => {
|
||
const currentToken = tokenRef.current;
|
||
if (currentToken) {
|
||
config.headers = config.headers || {};
|
||
if (!config.headers.Authorization) {
|
||
config.headers.Authorization = `Bearer ${currentToken}`;
|
||
}
|
||
}
|
||
return config;
|
||
});
|
||
|
||
const responseInterceptor = api.interceptors.response.use(
|
||
(response) => response,
|
||
async (error) => {
|
||
const { response, config } = error;
|
||
if (!response || !config) {
|
||
return Promise.reject(error);
|
||
}
|
||
|
||
const status = response.status;
|
||
const url = typeof config.url === 'string' ? config.url : '';
|
||
const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh');
|
||
|
||
if (status === 401 && !config._retry && !isAuthRoute) {
|
||
console.warn('[Auth] 401 received for', url, '- attempting token refresh');
|
||
|
||
if (!refreshPromiseRef.current) {
|
||
refreshPromiseRef.current = (async () => {
|
||
try {
|
||
return await refreshAccessToken();
|
||
} finally {
|
||
refreshPromiseRef.current = null;
|
||
}
|
||
})();
|
||
}
|
||
|
||
try {
|
||
const newToken = await refreshPromiseRef.current;
|
||
if (!newToken) {
|
||
throw new Error('No token returned from refresh');
|
||
}
|
||
config._retry = true;
|
||
config.headers = config.headers || {};
|
||
config.headers.Authorization = `Bearer ${newToken}`;
|
||
console.log('[Auth] Retrying original request', url);
|
||
return api(config);
|
||
} catch (refreshError) {
|
||
console.warn('[Auth] Refresh failed, clearing session');
|
||
setToken('');
|
||
setStatusMessage('Session expired. Please log in again.', 'error');
|
||
return Promise.reject(refreshError);
|
||
}
|
||
}
|
||
|
||
return Promise.reject(error);
|
||
},
|
||
);
|
||
|
||
return () => {
|
||
api.interceptors.request.eject(requestInterceptor);
|
||
api.interceptors.response.eject(responseInterceptor);
|
||
};
|
||
}, [refreshAccessToken, setStatusMessage]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedDocumentIds.length) {
|
||
if (activePreviewId !== null) {
|
||
setActivePreviewId(null);
|
||
}
|
||
return;
|
||
}
|
||
if (!selectedDocumentIds.includes(activePreviewId)) {
|
||
setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]);
|
||
}
|
||
initializedRef.current = true;
|
||
}, [selectedDocumentIds, activePreviewId]);
|
||
|
||
const currentFolderName = useMemo(() => {
|
||
if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME;
|
||
return currentFolder.name;
|
||
}, [selectedFolder, currentFolder]);
|
||
|
||
const isFilterActive = useMemo(
|
||
() => searchQuery.trim().length > 0 || activeTagFilters.length > 0,
|
||
[searchQuery, activeTagFilters],
|
||
);
|
||
|
||
const applySelectedFolder = useCallback(
|
||
(folderId, contents) => {
|
||
const subfolders = contents?.subfolders ?? [];
|
||
const docs = contents?.documents ?? [];
|
||
const folderInfo = contents?.folder ?? null;
|
||
|
||
setCurrentSubfolders(subfolders);
|
||
setDocuments(docs);
|
||
setCurrentFolder(folderInfo);
|
||
|
||
const availableIds = new Set(docs.map((doc) => doc.id));
|
||
let nextSelection = [];
|
||
|
||
setSelectedDocumentIds((previous) => {
|
||
if (initializedRef.current) {
|
||
nextSelection = previous.filter((id) => availableIds.has(id));
|
||
return nextSelection;
|
||
}
|
||
const filtered = previous.filter((id) => availableIds.has(id));
|
||
if (filtered.length) {
|
||
nextSelection = filtered;
|
||
return filtered;
|
||
}
|
||
if (docs.length) {
|
||
nextSelection = [docs[0].id];
|
||
return nextSelection;
|
||
}
|
||
nextSelection = [];
|
||
return [];
|
||
});
|
||
|
||
let nextFocus = focusedDocumentId && availableIds.has(focusedDocumentId)
|
||
? focusedDocumentId
|
||
: null;
|
||
if (!nextFocus && nextSelection.length) {
|
||
nextFocus = nextSelection[nextSelection.length - 1];
|
||
}
|
||
|
||
setFocusedDocumentId(nextFocus);
|
||
selectionAnchorRef.current = nextSelection.length
|
||
? nextSelection[nextSelection.length - 1]
|
||
: null;
|
||
selectionOrderRef.current = nextSelection;
|
||
setSelectionOrder(nextSelection);
|
||
|
||
return nextFocus;
|
||
},
|
||
[focusedDocumentId, setSelectionOrder],
|
||
);
|
||
|
||
const visibleDocuments = useMemo(
|
||
() => (searchResults !== null ? searchResults : documents),
|
||
[searchResults, documents],
|
||
);
|
||
|
||
const visibleDocumentIds = useMemo(
|
||
() => visibleDocuments.map((doc) => doc.id),
|
||
[visibleDocuments],
|
||
);
|
||
|
||
const applySelection = useCallback(
|
||
(ids, { anchor, interactedIds = [] } = {}) => {
|
||
const visibleSet = new Set(visibleDocumentIds);
|
||
const unique = [];
|
||
ids.forEach((id) => {
|
||
if (visibleSet.has(id) && !unique.includes(id)) {
|
||
unique.push(id);
|
||
}
|
||
});
|
||
|
||
let resolvedAnchor = anchor;
|
||
if (resolvedAnchor && !unique.includes(resolvedAnchor)) {
|
||
resolvedAnchor = null;
|
||
}
|
||
|
||
const nextFocus =
|
||
(focusedDocumentId && unique.includes(focusedDocumentId) && focusedDocumentId) ||
|
||
resolvedAnchor ||
|
||
(unique.length ? unique[unique.length - 1] : null);
|
||
|
||
setSelectedDocumentIds(unique);
|
||
setFocusedDocumentId(nextFocus);
|
||
updateSelectionOrder(unique, interactedIds);
|
||
|
||
if (resolvedAnchor) {
|
||
selectionAnchorRef.current = resolvedAnchor;
|
||
} else if (unique.length === 0) {
|
||
selectionAnchorRef.current = null;
|
||
} else if (
|
||
!selectionAnchorRef.current ||
|
||
!unique.includes(selectionAnchorRef.current)
|
||
) {
|
||
selectionAnchorRef.current = unique[unique.length - 1];
|
||
}
|
||
|
||
return { selection: unique, focus: nextFocus };
|
||
},
|
||
[visibleDocumentIds, focusedDocumentId, updateSelectionOrder],
|
||
);
|
||
|
||
const promoteSelectionOrder = useCallback(
|
||
(docId) => {
|
||
if (!docId) return;
|
||
if (!selectedDocumentIds.includes(docId)) return;
|
||
updateSelectionOrder(selectedDocumentIds, [docId]);
|
||
selectionAnchorRef.current = docId;
|
||
setFocusedDocumentId(docId);
|
||
setActivePreviewId(docId);
|
||
},
|
||
[selectedDocumentIds, updateSelectionOrder],
|
||
);
|
||
|
||
const visibleSelectedCount = useMemo(
|
||
() => selectedDocumentIds.filter((id) => visibleDocumentIds.includes(id)).length,
|
||
[selectedDocumentIds, visibleDocumentIds],
|
||
);
|
||
|
||
const allDocumentsSelected =
|
||
visibleDocumentIds.length > 0 &&
|
||
visibleSelectedCount === visibleDocumentIds.length;
|
||
const someDocumentsSelected =
|
||
visibleSelectedCount > 0 && !allDocumentsSelected;
|
||
|
||
const folderOptions = useMemo(() => {
|
||
const cache = new Map();
|
||
const computePath = (id) => {
|
||
if (cache.has(id)) {
|
||
return cache.get(id);
|
||
}
|
||
if (!id || id === 'root') {
|
||
cache.set('root', DEFAULT_FOLDER_NAME);
|
||
return DEFAULT_FOLDER_NAME;
|
||
}
|
||
const node = folderNodes.get(id);
|
||
if (!node) {
|
||
return 'Folder';
|
||
}
|
||
const parentId = node.parentId || 'root';
|
||
const parentPath = computePath(parentId);
|
||
const name = node.name || 'Folder';
|
||
const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`;
|
||
cache.set(id, fullPath);
|
||
return fullPath;
|
||
};
|
||
|
||
const entries = [];
|
||
folderNodes.forEach((node, id) => {
|
||
if (!node) return;
|
||
entries.push({ id, label: computePath(id) });
|
||
});
|
||
|
||
entries.sort((a, b) => {
|
||
if (a.id === 'root') return -1;
|
||
if (b.id === 'root') return 1;
|
||
return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
|
||
});
|
||
|
||
return entries;
|
||
}, [folderNodes]);
|
||
|
||
const folderLabelMap = useMemo(() => {
|
||
const map = new Map();
|
||
folderOptions.forEach((option) => {
|
||
map.set(option.id, option.label);
|
||
});
|
||
return map;
|
||
}, [folderOptions]);
|
||
|
||
const handleDocumentRowClick = useCallback(
|
||
(documentId, event) => {
|
||
const { shiftKey, metaKey, ctrlKey } = event;
|
||
const additive = metaKey || ctrlKey;
|
||
if (shiftKey) {
|
||
event.preventDefault();
|
||
}
|
||
if (!visibleDocumentIds.includes(documentId)) {
|
||
return;
|
||
}
|
||
|
||
let anchor = selectionAnchorRef.current;
|
||
if (!anchor && shiftKey && selectedDocumentIds.length) {
|
||
anchor = selectedDocumentIds[selectedDocumentIds.length - 1];
|
||
}
|
||
if (!anchor) {
|
||
anchor = documentId;
|
||
}
|
||
let nextIds;
|
||
let interactedIds = [];
|
||
|
||
if (shiftKey && anchor) {
|
||
const anchorIndex = visibleDocumentIds.indexOf(anchor);
|
||
const targetIndex = visibleDocumentIds.indexOf(documentId);
|
||
if (anchorIndex !== -1 && targetIndex !== -1) {
|
||
const [start, end] =
|
||
anchorIndex <= targetIndex
|
||
? [anchorIndex, targetIndex]
|
||
: [targetIndex, anchorIndex];
|
||
const range = visibleDocumentIds.slice(start, end + 1);
|
||
nextIds = range;
|
||
|
||
const ordered = anchorIndex <= targetIndex ? range : [...range].reverse();
|
||
const previousSet = new Set(selectedDocumentIds);
|
||
interactedIds = ordered.filter(
|
||
(id) => id === documentId || !previousSet.has(id),
|
||
);
|
||
if (!interactedIds.includes(documentId)) {
|
||
interactedIds.push(documentId);
|
||
}
|
||
} else {
|
||
nextIds = [documentId];
|
||
interactedIds = [documentId];
|
||
}
|
||
} else if (additive) {
|
||
if (selectedDocumentIds.includes(documentId)) {
|
||
nextIds = selectedDocumentIds.filter((id) => id !== documentId);
|
||
interactedIds = [];
|
||
} else {
|
||
nextIds = [...selectedDocumentIds, documentId];
|
||
interactedIds = [documentId];
|
||
}
|
||
anchor = documentId;
|
||
} else {
|
||
nextIds = [documentId];
|
||
interactedIds = [documentId];
|
||
anchor = documentId;
|
||
}
|
||
|
||
applySelection(nextIds, { anchor, interactedIds });
|
||
},
|
||
[
|
||
visibleDocumentIds,
|
||
selectedDocumentIds,
|
||
applySelection,
|
||
],
|
||
);
|
||
|
||
const handleDocumentDragStart = useCallback(
|
||
(event, documentId) => {
|
||
const selection = selectedDocumentIds.includes(documentId)
|
||
? selectedDocumentIds
|
||
: [documentId];
|
||
|
||
if (!selectedDocumentIds.includes(documentId)) {
|
||
applySelection([documentId], {
|
||
anchor: documentId,
|
||
interactedIds: [documentId],
|
||
});
|
||
}
|
||
|
||
setDraggedDocumentIds(selection);
|
||
event.dataTransfer.effectAllowed = 'move';
|
||
try {
|
||
event.dataTransfer.setData(
|
||
'application/x-paperless-doc-list',
|
||
JSON.stringify(selection),
|
||
);
|
||
} catch (
|
||
// eslint-disable-next-line no-empty
|
||
error
|
||
) {}
|
||
event.currentTarget.classList.add('dragging');
|
||
},
|
||
[selectedDocumentIds, applySelection],
|
||
);
|
||
|
||
const handleDocumentDragEnd = useCallback((event) => {
|
||
setDraggedDocumentIds([]);
|
||
event.currentTarget.classList.remove('dragging');
|
||
}, []);
|
||
|
||
const ensureFolderData = useCallback(
|
||
async (folderId, { force = false } = {}) => {
|
||
if (!force && folderContents.has(folderId)) {
|
||
return folderContents.get(folderId);
|
||
}
|
||
|
||
const path = folderId === 'root' ? 'root' : folderId;
|
||
const { data } = await api.get(`/folders/${path}/contents`);
|
||
|
||
setFolderNodes((prev) => {
|
||
const next = new Map(prev);
|
||
const existingNode = next.get(folderId) || {
|
||
id: folderId,
|
||
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder',
|
||
parentId: data.folder?.parent_id || 'root',
|
||
children: [],
|
||
expanded: folderId === 'root',
|
||
loaded: false,
|
||
};
|
||
|
||
const childIds = (data.subfolders || []).map((child) => child.id);
|
||
next.set(folderId, {
|
||
...existingNode,
|
||
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name,
|
||
parentId: data.folder?.parent_id ?? existingNode.parentId ?? 'root',
|
||
children: childIds,
|
||
loaded: true,
|
||
});
|
||
|
||
(data.subfolders || []).forEach((child) => {
|
||
const childNode = next.get(child.id);
|
||
next.set(child.id, {
|
||
id: child.id,
|
||
name: child.name,
|
||
parentId: child.parent_id ?? 'root',
|
||
children: childNode?.children ?? [],
|
||
expanded: childNode?.expanded ?? false,
|
||
loaded: childNode?.loaded ?? false,
|
||
});
|
||
});
|
||
|
||
return next;
|
||
});
|
||
|
||
setFolderContents((prev) => {
|
||
const next = new Map(prev);
|
||
next.set(folderId, data);
|
||
return next;
|
||
});
|
||
|
||
return data;
|
||
},
|
||
[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;
|
||
const cached = documentDetailsRef.current.get(documentId);
|
||
if (!force && cached) {
|
||
return cached;
|
||
}
|
||
|
||
const { data } = await api.get(`/documents/${documentId}`);
|
||
|
||
setDocumentDetails((prev) => {
|
||
const next = new Map(prev);
|
||
next.set(documentId, data);
|
||
documentDetailsRef.current = next;
|
||
return next;
|
||
});
|
||
|
||
setDocuments((prev) =>
|
||
prev.map((doc) => (doc.id === documentId ? data.document : doc)),
|
||
);
|
||
|
||
setSearchResults((prev) =>
|
||
prev ? prev.map((doc) => (doc.id === documentId ? data.document : doc)) : null,
|
||
);
|
||
|
||
return data;
|
||
},
|
||
[setDocumentDetails, setDocuments, setSearchResults],
|
||
);
|
||
|
||
const refreshTags = useCallback(async () => {
|
||
try {
|
||
const { data } = await api.get('/tags');
|
||
setTags(data || []);
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatusMessage('Unable to load tags.', 'error');
|
||
}
|
||
}, [setStatusMessage]);
|
||
|
||
const handleTagUpdate = useCallback(
|
||
async (tagId, changes) => {
|
||
if (!tagId) {
|
||
throw new Error('Missing tag identifier.');
|
||
}
|
||
|
||
const payload = {};
|
||
if (typeof changes.label === 'string') {
|
||
payload.label = changes.label;
|
||
}
|
||
if (Object.prototype.hasOwnProperty.call(changes, 'color')) {
|
||
payload.color = changes.color;
|
||
}
|
||
|
||
if (Object.keys(payload).length === 0) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
await api.patch(`/tags/${tagId}`, payload);
|
||
await refreshTags();
|
||
setStatusMessage('Tag updated.', 'success');
|
||
return true;
|
||
} catch (error) {
|
||
console.error(error);
|
||
const message = error.response?.data?.error || 'Failed to update tag.';
|
||
setStatusMessage(message, 'error');
|
||
throw new Error(message);
|
||
}
|
||
},
|
||
[api, refreshTags, setStatusMessage],
|
||
);
|
||
|
||
const loadFolder = useCallback(
|
||
async (folderId, { showLoading = true } = {}) => {
|
||
const targetId = folderId || 'root';
|
||
setSelectedFolder(targetId);
|
||
if (showLoading) setLoading(true);
|
||
try {
|
||
const contents = await ensureFolderData(targetId, { force: true });
|
||
const nextSelectedId = applySelectedFolder(targetId, contents);
|
||
setSearchResults(null);
|
||
if (nextSelectedId) {
|
||
await ensureDocumentDetail(nextSelectedId, { force: true });
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatusMessage('Failed to load folder contents.', 'error');
|
||
} finally {
|
||
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 {
|
||
await refreshTags();
|
||
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
|
||
await loadFolder(initialFolder, { showLoading: false });
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatusMessage('Failed to initialize data.', 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [refreshTags, routeFolderId, loadFolder, setStatusMessage]);
|
||
|
||
useEffect(() => {
|
||
const targetParam = routeFolderId ?? 'root';
|
||
|
||
if (targetParam === 'root' && routeDocumentId) {
|
||
return;
|
||
}
|
||
|
||
const hasData = folderContents.has(targetParam);
|
||
if (targetParam !== selectedFolder || !hasData) {
|
||
loadFolder(targetParam, { showLoading: !routeDocumentId });
|
||
}
|
||
}, [routeFolderId, routeDocumentId, selectedFolder, loadFolder, folderContents]);
|
||
|
||
const refreshCurrentFolder = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const contents = await ensureFolderData(selectedFolder, { force: true });
|
||
const nextSelectedId = applySelectedFolder(selectedFolder, contents);
|
||
if (nextSelectedId) {
|
||
await ensureDocumentDetail(nextSelectedId, { force: true });
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatusMessage('Failed to refresh folder.', 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [selectedFolder, ensureFolderData, applySelectedFolder, ensureDocumentDetail, setStatusMessage]);
|
||
|
||
useEffect(() => {
|
||
if (!token || initializedRef.current) return;
|
||
initializedRef.current = true;
|
||
initializeAfterLogin();
|
||
}, [token, initializeAfterLogin]);
|
||
|
||
const handleBulkMoveSubmit = useCallback(
|
||
async (event) => {
|
||
event.preventDefault();
|
||
if (!selectedDocumentIds.length) {
|
||
setStatusMessage('Select documents before moving.', 'error');
|
||
return;
|
||
}
|
||
|
||
const form = new FormData(event.currentTarget);
|
||
const target = form.get('target')?.toString() || 'root';
|
||
const folderId = target === 'root' ? null : target;
|
||
|
||
setLoading(true);
|
||
try {
|
||
await api.post('/documents/bulk/move', {
|
||
document_ids: selectedDocumentIds,
|
||
folder_id: folderId,
|
||
});
|
||
|
||
const count = selectedDocumentIds.length;
|
||
const suffix = count === 1 ? '' : 's';
|
||
const folderLabel =
|
||
folderId === null
|
||
? DEFAULT_FOLDER_NAME
|
||
: folderLabelMap.get(target) || 'target folder';
|
||
setStatusMessage(
|
||
`Moved ${count} document${suffix} to ${folderLabel}.`,
|
||
'success',
|
||
);
|
||
|
||
applySelection([], { anchor: null });
|
||
|
||
await refreshCurrentFolder();
|
||
if (folderId && folderId !== selectedFolder) {
|
||
await ensureFolderData(folderId, { force: true });
|
||
}
|
||
|
||
event.currentTarget.reset();
|
||
} catch (error) {
|
||
console.error(error);
|
||
const message = error.response?.data?.error || 'Failed to move documents.';
|
||
setStatusMessage(message, 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[
|
||
api,
|
||
selectedDocumentIds,
|
||
folderLabelMap,
|
||
applySelection,
|
||
refreshCurrentFolder,
|
||
ensureFolderData,
|
||
selectedFolder,
|
||
setStatusMessage,
|
||
],
|
||
);
|
||
|
||
const parseTagInput = useCallback((value) => {
|
||
return value
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
}, []);
|
||
|
||
const bulkTagOperation = useCallback(
|
||
async ({ labels, action }) => {
|
||
const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0);
|
||
if (!normalized.length) {
|
||
return { ok: false, reason: 'no-labels' };
|
||
}
|
||
if (!selectedDocumentIds.length) {
|
||
return { ok: false, reason: 'no-selection' };
|
||
}
|
||
|
||
let tagIds = [];
|
||
|
||
if (action === 'remove') {
|
||
const missing = normalized.find(
|
||
(label) => !tags.some((tag) => tag.label.toLowerCase() === label.toLowerCase()),
|
||
);
|
||
if (missing) {
|
||
return { ok: false, reason: 'tag-missing', label: missing };
|
||
}
|
||
|
||
tagIds = normalized.map((label) => {
|
||
const tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase());
|
||
return tag?.id;
|
||
}).filter(Boolean);
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
if (action === 'add') {
|
||
const createdIds = [];
|
||
for (const label of normalized) {
|
||
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
|
||
if (!tag) {
|
||
const { data } = await api.post('/tags', { label, color: null });
|
||
tag = data;
|
||
await refreshTags();
|
||
}
|
||
createdIds.push(tag.id);
|
||
}
|
||
tagIds = Array.from(new Set(createdIds));
|
||
}
|
||
|
||
tagIds = Array.from(new Set(tagIds));
|
||
|
||
if (!tagIds.length) {
|
||
return { ok: false, reason: 'no-tags' };
|
||
}
|
||
|
||
await api.post('/documents/bulk/tags', {
|
||
document_ids: selectedDocumentIds,
|
||
tag_ids: tagIds,
|
||
action,
|
||
});
|
||
|
||
await Promise.all(
|
||
selectedDocumentIds.map((id) => ensureDocumentDetail(id, { force: true }).catch(() => null)),
|
||
);
|
||
|
||
return {
|
||
ok: true,
|
||
tagCount: tagIds.length,
|
||
docsCount: selectedDocumentIds.length,
|
||
};
|
||
} catch (error) {
|
||
console.error(error);
|
||
const message =
|
||
error.response?.data?.error ||
|
||
(action === 'add' ? 'Failed to assign tags.' : 'Failed to remove tags.');
|
||
setStatusMessage(message, 'error');
|
||
return { ok: false, reason: 'request-failed' };
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[selectedDocumentIds, tags, api, refreshTags, ensureDocumentDetail, setStatusMessage, setLoading],
|
||
);
|
||
|
||
const handleBulkTagAdd = useCallback(
|
||
async (event) => {
|
||
event.preventDefault();
|
||
if (!selectedDocumentIds.length) {
|
||
setStatusMessage('Select documents before assigning tags.', 'error');
|
||
return;
|
||
}
|
||
|
||
const form = new FormData(event.currentTarget);
|
||
const raw = form.get('tags')?.toString().trim() || '';
|
||
const labels = parseTagInput(raw);
|
||
if (!labels.length) {
|
||
setStatusMessage('Enter at least one tag label.', 'error');
|
||
return;
|
||
}
|
||
const result = await bulkTagOperation({ labels, action: 'add' });
|
||
if (result?.ok) {
|
||
const { tagCount, docsCount } = result;
|
||
setStatusMessage(
|
||
`Assigned ${tagCount} tag${tagCount === 1 ? '' : 's'} to ${docsCount} document${
|
||
docsCount === 1 ? '' : 's'
|
||
}.`,
|
||
'success',
|
||
);
|
||
event.currentTarget.reset();
|
||
} else if (result?.reason === 'no-labels') {
|
||
setStatusMessage('Enter at least one tag label.', 'error');
|
||
}
|
||
},
|
||
[
|
||
selectedDocumentIds,
|
||
setStatusMessage,
|
||
parseTagInput,
|
||
bulkTagOperation,
|
||
],
|
||
);
|
||
|
||
const handleBulkTagRemove = useCallback(
|
||
async (event) => {
|
||
event.preventDefault();
|
||
if (!selectedDocumentIds.length) {
|
||
setStatusMessage('Select documents before removing tags.', 'error');
|
||
return;
|
||
}
|
||
|
||
const form = new FormData(event.currentTarget);
|
||
const raw = form.get('tags')?.toString().trim() || '';
|
||
const labels = parseTagInput(raw);
|
||
if (!labels.length) {
|
||
setStatusMessage('Enter at least one tag label to remove.', 'error');
|
||
return;
|
||
}
|
||
|
||
const result = await bulkTagOperation({ labels, action: 'remove' });
|
||
if (result?.ok) {
|
||
const { docsCount } = result;
|
||
setStatusMessage(
|
||
`Removed tags from ${docsCount} document${docsCount === 1 ? '' : 's'}.`,
|
||
'success',
|
||
);
|
||
event.currentTarget.reset();
|
||
} else if (result?.reason === 'no-labels') {
|
||
setStatusMessage('Enter at least one tag label to remove.', 'error');
|
||
} else if (result?.reason === 'tag-missing') {
|
||
setStatusMessage(`Tag “${result.label}” not found.`, 'error');
|
||
}
|
||
},
|
||
[
|
||
selectedDocumentIds,
|
||
setStatusMessage,
|
||
parseTagInput,
|
||
bulkTagOperation,
|
||
],
|
||
);
|
||
|
||
const handleBulkTagAddFromDetail = useCallback(
|
||
async ({ label, input }) => {
|
||
const trimmed = (label || '').trim();
|
||
if (!trimmed) {
|
||
setStatusMessage('Enter a tag label.', 'error');
|
||
return;
|
||
}
|
||
if (!selectedDocumentIds.length) {
|
||
setStatusMessage('Select documents before assigning tags.', 'error');
|
||
return;
|
||
}
|
||
const result = await bulkTagOperation({ labels: [trimmed], action: 'add' });
|
||
if (result?.ok) {
|
||
const { tagCount, docsCount } = result;
|
||
setStatusMessage(
|
||
`Assigned ${tagCount} tag${tagCount === 1 ? '' : 's'} to ${docsCount} document${
|
||
docsCount === 1 ? '' : 's'
|
||
}.`,
|
||
'success',
|
||
);
|
||
if (input) {
|
||
input.value = '';
|
||
}
|
||
}
|
||
},
|
||
[bulkTagOperation, selectedDocumentIds, setStatusMessage],
|
||
);
|
||
|
||
const handleBulkTagRemoveFromDetail = useCallback(
|
||
async ({ label, input }) => {
|
||
const trimmed = (label || '').trim();
|
||
if (!trimmed) {
|
||
setStatusMessage('Enter a tag label to remove.', 'error');
|
||
return;
|
||
}
|
||
if (!selectedDocumentIds.length) {
|
||
setStatusMessage('Select documents before removing tags.', 'error');
|
||
return;
|
||
}
|
||
const result = await bulkTagOperation({ labels: [trimmed], action: 'remove' });
|
||
if (result?.ok) {
|
||
const { docsCount } = result;
|
||
setStatusMessage(
|
||
`Removed tags from ${docsCount} document${docsCount === 1 ? '' : 's'}.`,
|
||
'success',
|
||
);
|
||
if (input) {
|
||
input.value = '';
|
||
}
|
||
} else if (result?.reason === 'tag-missing') {
|
||
setStatusMessage(`Tag “${result.label}” not found.`, 'error');
|
||
}
|
||
},
|
||
[bulkTagOperation, selectedDocumentIds, setStatusMessage],
|
||
);
|
||
|
||
const handleBulkSelectionReanalyze = useCallback(async () => {
|
||
if (!selectedDocumentIds.length) {
|
||
setStatusMessage('Select documents before requesting re-analysis.', 'error');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const { data } = await api.post('/documents/bulk/reanalyze', {
|
||
document_ids: selectedDocumentIds,
|
||
force: true,
|
||
});
|
||
const queued = data?.queued ?? selectedDocumentIds.length;
|
||
setStatusMessage(
|
||
`Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`,
|
||
'success',
|
||
);
|
||
} catch (error) {
|
||
console.error(error);
|
||
const message =
|
||
error.response?.data?.error || 'Failed to queue document re-analysis.';
|
||
setStatusMessage(message, 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [selectedDocumentIds, api, setStatusMessage]);
|
||
|
||
const uploadFile = useCallback(
|
||
async (file, targetFolderId) => {
|
||
if (!file || file.size === 0) {
|
||
setStatusMessage('Skipped empty file.', 'error');
|
||
return null;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', file, file.name);
|
||
if (targetFolderId && targetFolderId !== 'root') {
|
||
formData.append('folder_id', targetFolderId);
|
||
}
|
||
|
||
try {
|
||
const { data, status } = await api.post('/documents', formData);
|
||
const duplicate = data?.reused || status === 200;
|
||
setStatusMessage(
|
||
duplicate
|
||
? `${file.name} already exists; reused existing document.`
|
||
: `Uploaded ${file.name}`,
|
||
duplicate ? 'info' : 'success',
|
||
);
|
||
return data;
|
||
} catch (error) {
|
||
console.error(error);
|
||
const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
|
||
setStatusMessage(message, 'error');
|
||
throw error;
|
||
}
|
||
},
|
||
[setStatusMessage],
|
||
);
|
||
|
||
const folderPathCacheRef = useRef(new Map());
|
||
const previewCacheRef = useRef(new Map());
|
||
const [previewCacheTick, setPreviewCacheTick] = useState(0);
|
||
|
||
const ensureFolderPathOnServer = useCallback(
|
||
async (baseFolderId, segments) => {
|
||
const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
|
||
if (trimmedSegments.length === 0) {
|
||
return baseFolderId ?? null;
|
||
}
|
||
|
||
const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`;
|
||
const cache = folderPathCacheRef.current;
|
||
if (cache.has(cacheKey)) {
|
||
return cache.get(cacheKey);
|
||
}
|
||
|
||
const payload = {
|
||
parent_id: baseFolderId && baseFolderId !== 'root' ? baseFolderId : null,
|
||
segments: trimmedSegments,
|
||
};
|
||
|
||
const { data } = await api.post('/folders/path', payload);
|
||
const folderId = data.folder.id;
|
||
cache.set(cacheKey, folderId);
|
||
return folderId;
|
||
},
|
||
[],
|
||
);
|
||
|
||
const ensurePreviewUrl = useCallback(
|
||
async (documentId, { force = false } = {}) => {
|
||
if (!documentId) return null;
|
||
|
||
const cached = previewCacheRef.current.get(documentId);
|
||
const now = Date.now();
|
||
if (!force && cached && (!cached.expiresAt || cached.expiresAt > now)) {
|
||
return cached;
|
||
}
|
||
|
||
try {
|
||
const { data } = await api.get(`/documents/${documentId}/download`);
|
||
const ttl = data.expires_in ? Math.max(data.expires_in - 60, 30) * 1000 : 5 * 60 * 1000;
|
||
const entry = {
|
||
url: data.url,
|
||
contentType: data.content_type || null,
|
||
filename: data.filename,
|
||
expiresAt: now + ttl,
|
||
};
|
||
|
||
previewCacheRef.current.set(documentId, entry);
|
||
setPreviewCacheTick((prev) => prev + 1);
|
||
return entry;
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatusMessage('Unable to fetch document preview.', 'error');
|
||
throw error;
|
||
}
|
||
},
|
||
[setStatusMessage],
|
||
);
|
||
|
||
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
|
||
if (
|
||
!dataTransfer ||
|
||
!dataTransfer.items ||
|
||
!window.isSecureContext ||
|
||
!Array.from(dataTransfer.items).some((item) => typeof item.getAsFileSystemHandle === 'function')
|
||
) {
|
||
throw new Error('File System Access API is required for uploads.');
|
||
}
|
||
|
||
const results = [];
|
||
|
||
const pushFile = (file, ancestors) => {
|
||
if (file) {
|
||
results.push({
|
||
file,
|
||
segments: ancestors.filter(Boolean),
|
||
});
|
||
}
|
||
};
|
||
|
||
const walkDirectoryHandle = async (handle, ancestors) => {
|
||
const nextAncestors = handle.name ? [...ancestors, handle.name] : [...ancestors];
|
||
for await (const child of handle.values()) {
|
||
if (child.kind === 'file') {
|
||
const file = await child.getFile();
|
||
pushFile(file, nextAncestors);
|
||
} else if (child.kind === 'directory') {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
await walkDirectoryHandle(child, nextAncestors);
|
||
}
|
||
}
|
||
};
|
||
|
||
for (const item of Array.from(dataTransfer.items)) {
|
||
if (item.kind !== 'file') continue;
|
||
const getHandle = item.getAsFileSystemHandle?.bind(item);
|
||
if (!getHandle) {
|
||
throw new Error('File System Access API handle missing.');
|
||
}
|
||
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const handle = await getHandle();
|
||
if (!handle) continue;
|
||
|
||
if (handle.kind === 'file') {
|
||
const file = await handle.getFile();
|
||
pushFile(file, []);
|
||
} else if (handle.kind === 'directory') {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
await walkDirectoryHandle(handle, []);
|
||
}
|
||
}
|
||
|
||
if (!results.length) {
|
||
throw new Error('No files detected in drop payload.');
|
||
}
|
||
|
||
return results;
|
||
}, []);
|
||
|
||
const handleFileDrop = useCallback(
|
||
async (dataTransfer, targetFolderId) => {
|
||
if (!token) {
|
||
setStatusMessage('Please log in before uploading.', 'error');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
|
||
try {
|
||
folderPathCacheRef.current.clear();
|
||
|
||
let extracted;
|
||
try {
|
||
extracted = await extractFilesFromDataTransfer(dataTransfer);
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatusMessage(error.message || 'Failed to process dropped files.', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!extracted.length) {
|
||
setStatusMessage('No files to upload.', 'info');
|
||
return;
|
||
}
|
||
const baseFolderId =
|
||
targetFolderId && targetFolderId !== 'root' ? targetFolderId : null;
|
||
|
||
for (const { file, segments } of extracted) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const destinationId = segments.length
|
||
? await ensureFolderPathOnServer(baseFolderId, segments)
|
||
: baseFolderId;
|
||
|
||
const uploadTarget =
|
||
destinationId ??
|
||
(targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root');
|
||
|
||
// eslint-disable-next-line no-await-in-loop
|
||
await uploadFile(file, uploadTarget);
|
||
}
|
||
|
||
await refreshCurrentFolder();
|
||
|
||
if (
|
||
targetFolderId &&
|
||
targetFolderId !== 'root' &&
|
||
targetFolderId !== selectedFolder
|
||
) {
|
||
await ensureFolderData(targetFolderId, { force: true });
|
||
}
|
||
} catch (error) {
|
||
// errors surfaced through setStatusMessage
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[
|
||
token,
|
||
extractFilesFromDataTransfer,
|
||
ensureFolderPathOnServer,
|
||
uploadFile,
|
||
refreshCurrentFolder,
|
||
selectedFolder,
|
||
ensureFolderData,
|
||
setStatusMessage,
|
||
],
|
||
);
|
||
|
||
const moveDocumentsToFolder = useCallback(
|
||
async (documentIds, targetFolderId) => {
|
||
const unique = Array.from(new Set(documentIds || [])).filter(Boolean);
|
||
if (!unique.length) return;
|
||
|
||
const target = targetFolderId === 'root' ? null : targetFolderId;
|
||
setLoading(true);
|
||
try {
|
||
if (unique.length === 1) {
|
||
await api.patch(`/documents/${unique[0]}/folder`, { folder_id: target });
|
||
} else {
|
||
await api.post('/documents/bulk/move', {
|
||
document_ids: unique,
|
||
folder_id: target,
|
||
});
|
||
}
|
||
|
||
const count = unique.length;
|
||
const suffix = count === 1 ? '' : 's';
|
||
const targetLabel =
|
||
target === null
|
||
? DEFAULT_FOLDER_NAME
|
||
: folderLabelMap.get(targetFolderId) || 'target folder';
|
||
setStatusMessage(
|
||
`Moved ${count} document${suffix} to ${targetLabel}.`,
|
||
'success',
|
||
);
|
||
|
||
setDocumentDetails((prev) => {
|
||
const next = new Map(prev);
|
||
unique.forEach((id) => next.delete(id));
|
||
return next;
|
||
});
|
||
|
||
await refreshCurrentFolder();
|
||
if (targetFolderId && targetFolderId !== selectedFolder) {
|
||
await ensureFolderData(targetFolderId, { force: true });
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
const message = error.response?.data?.error || 'Failed to move documents.';
|
||
setStatusMessage(message, 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[
|
||
api,
|
||
ensureFolderData,
|
||
refreshCurrentFolder,
|
||
selectedFolder,
|
||
folderLabelMap,
|
||
setStatusMessage,
|
||
],
|
||
);
|
||
|
||
const handleBulkMoveFromDetail = useCallback(
|
||
async ({ target }) => {
|
||
const destination = target === 'root' ? 'root' : target;
|
||
if (!selectedDocumentIds.length) {
|
||
setStatusMessage('Select documents before moving.', 'error');
|
||
return;
|
||
}
|
||
await moveDocumentsToFolder(selectedDocumentIds, destination);
|
||
},
|
||
[selectedDocumentIds, moveDocumentsToFolder, setStatusMessage],
|
||
);
|
||
|
||
const handleThumbnailRegeneration = useCallback(
|
||
async (documentId) => {
|
||
if (!token) {
|
||
setStatusMessage('Log in to manage assets.', 'error');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
await api.post(`/documents/${documentId}/assets`, null, {
|
||
params: { force: true },
|
||
});
|
||
setStatusMessage('Document re-analysis queued.', 'info');
|
||
await ensureDocumentDetail(documentId, { force: true });
|
||
} catch (error) {
|
||
console.error(error);
|
||
const message =
|
||
error.response?.data?.error || 'Failed to request thumbnail generation.';
|
||
setStatusMessage(message, 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[token, ensureDocumentDetail, setStatusMessage],
|
||
);
|
||
|
||
const openDocumentPreview = useCallback(
|
||
async (documentId, { replace = false, skipNavigate = false } = {}) => {
|
||
if (!documentId) return;
|
||
setPreviewDocumentId(documentId);
|
||
setPreviewDocumentLoading(true);
|
||
try {
|
||
await ensureDocumentDetail(documentId, { force: false });
|
||
await ensurePreviewUrl(documentId, { force: false });
|
||
setActivePreviewId(documentId);
|
||
if (!skipNavigate && navigate) {
|
||
navigate(`/documents/${documentId}`, { replace });
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
setPreviewDocumentId(null);
|
||
} finally {
|
||
setPreviewDocumentLoading(false);
|
||
}
|
||
},
|
||
[ensureDocumentDetail, ensurePreviewUrl, navigate],
|
||
);
|
||
|
||
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;
|
||
const handleKeyDown = (event) => {
|
||
if (event.key === 'Escape') {
|
||
closeDocumentPreview();
|
||
}
|
||
};
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
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 {
|
||
await api.delete(`/documents/${documentId}/tags/${tagId}`);
|
||
setStatusMessage('Tag removed.', 'success');
|
||
await Promise.all([
|
||
refreshCurrentFolder(),
|
||
refreshTags(),
|
||
ensureDocumentDetail(documentId, { force: true }),
|
||
]);
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatusMessage('Failed to remove tag.', 'error');
|
||
}
|
||
},
|
||
[ensureDocumentDetail, refreshCurrentFolder, refreshTags, setStatusMessage],
|
||
);
|
||
|
||
const handleTagAdd = useCallback(
|
||
async (document, label, input) => {
|
||
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
|
||
try {
|
||
if (!tag) {
|
||
const { data } = await api.post('/tags', { label, color: null });
|
||
tag = data;
|
||
await refreshTags();
|
||
}
|
||
await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] });
|
||
setStatusMessage('Tag assigned.', 'success');
|
||
input.value = '';
|
||
await Promise.all([
|
||
refreshCurrentFolder(),
|
||
ensureDocumentDetail(document.id, { force: true }),
|
||
]);
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatusMessage('Failed to assign tag.', 'error');
|
||
}
|
||
},
|
||
[tags, refreshTags, refreshCurrentFolder, ensureDocumentDetail, setStatusMessage],
|
||
);
|
||
|
||
const handleFolderDelete = useCallback(
|
||
async (folderId) => {
|
||
if (!token) {
|
||
setStatusMessage('Log in to manage folders.', 'error');
|
||
return;
|
||
}
|
||
if (folderId === 'root') {
|
||
setStatusMessage('The root folder cannot be removed.', 'error');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const contents = await ensureFolderData(folderId, { force: true });
|
||
const hasChildren = (contents.subfolders || []).length > 0;
|
||
const hasDocs = (contents.documents || []).length > 0;
|
||
if (hasChildren || hasDocs) {
|
||
setStatusMessage('Folder must be empty before it can be deleted.', 'error');
|
||
return;
|
||
}
|
||
await api.delete(`/folders/${folderId}`);
|
||
setFolderNodes((prev) => {
|
||
const next = new Map(prev);
|
||
const node = next.get(folderId);
|
||
next.delete(folderId);
|
||
if (node) {
|
||
const parentId = node.parentId || 'root';
|
||
const parentNode = next.get(parentId);
|
||
if (parentNode) {
|
||
next.set(parentId, {
|
||
...parentNode,
|
||
children: parentNode.children.filter((id) => id !== folderId),
|
||
});
|
||
}
|
||
}
|
||
return next;
|
||
});
|
||
setFolderContents((prev) => {
|
||
const next = new Map(prev);
|
||
next.delete(folderId);
|
||
return next;
|
||
});
|
||
if (selectedFolder === folderId) {
|
||
const node = folderNodes.get(folderId);
|
||
const parentId = node?.parentId || 'root';
|
||
setSelectedFolder(parentId);
|
||
const parentContents = await ensureFolderData(parentId, { force: true });
|
||
const nextSelectedId = applySelectedFolder(parentId, parentContents);
|
||
if (nextSelectedId) {
|
||
await ensureDocumentDetail(nextSelectedId, { force: true });
|
||
}
|
||
} else if (selectedFolder !== 'root') {
|
||
await ensureFolderData(selectedFolder, { force: true });
|
||
}
|
||
setStatusMessage('Folder deleted.', 'success');
|
||
} catch (error) {
|
||
console.error(error);
|
||
const message = error.response?.data?.error || 'Failed to delete folder.';
|
||
setStatusMessage(message, 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[token, ensureFolderData, selectedFolder, folderNodes, applySelectedFolder, ensureDocumentDetail, setStatusMessage],
|
||
);
|
||
|
||
const handleFolderCreate = useCallback(
|
||
async (name, onSuccess) => {
|
||
if (!token) {
|
||
setStatusMessage('Log in to create folders.', 'error');
|
||
return;
|
||
}
|
||
if (!name.trim()) {
|
||
setStatusMessage('Folder name cannot be empty.', 'error');
|
||
return;
|
||
}
|
||
const payload = {
|
||
name: name.trim(),
|
||
parent_id: selectedFolder === 'root' ? null : selectedFolder,
|
||
};
|
||
setLoading(true);
|
||
try {
|
||
const { data } = await api.post('/folders', payload);
|
||
onSuccess();
|
||
setStatusMessage('Folder created.', 'success');
|
||
setFolderNodes((prev) => {
|
||
const next = new Map(prev);
|
||
const parentId = payload.parent_id || 'root';
|
||
const parentNode = next.get(parentId);
|
||
if (parentNode) {
|
||
next.set(parentId, {
|
||
...parentNode,
|
||
children: parentNode.children.concat([data.folder.id]),
|
||
loaded: true,
|
||
});
|
||
}
|
||
next.set(data.folder.id, {
|
||
id: data.folder.id,
|
||
name: data.folder.name,
|
||
parentId: parentId,
|
||
children: [],
|
||
expanded: false,
|
||
loaded: false,
|
||
});
|
||
return next;
|
||
});
|
||
await ensureFolderData(selectedFolder, { force: true });
|
||
} catch (error) {
|
||
console.error(error);
|
||
const message = error.response?.data?.error || 'Failed to create folder.';
|
||
setStatusMessage(message, 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[token, selectedFolder, ensureFolderData, setStatusMessage],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!token) return undefined;
|
||
|
||
if (!isFilterActive) {
|
||
setSearchResults(null);
|
||
return undefined;
|
||
}
|
||
|
||
let cancelled = false;
|
||
let started = false;
|
||
|
||
const debounce = setTimeout(async () => {
|
||
started = true;
|
||
setLoading(true);
|
||
try {
|
||
const params = {};
|
||
const trimmedQuery = searchQuery.trim();
|
||
if (trimmedQuery.length) {
|
||
params.query = trimmedQuery;
|
||
}
|
||
if (activeTagFilters.length) {
|
||
params.tags = activeTagFilters.join(',');
|
||
}
|
||
const folderIdentifier = selectedFolder === 'root' ? 'root' : selectedFolder;
|
||
const { data } = await api.get(`/folders/${folderIdentifier}/documents`, {
|
||
params,
|
||
});
|
||
if (cancelled) return;
|
||
|
||
const results = data || [];
|
||
setSearchResults(results);
|
||
|
||
if (!results.length) {
|
||
setSelectedDocumentIds([]);
|
||
setFocusedDocumentId(null);
|
||
selectionAnchorRef.current = null;
|
||
return;
|
||
}
|
||
|
||
const resultIds = results.map((doc) => doc.id);
|
||
let targetId = null;
|
||
setSelectedDocumentIds((previous) => {
|
||
const filtered = previous.filter((id) => resultIds.includes(id));
|
||
if (filtered.length) {
|
||
targetId = filtered[filtered.length - 1];
|
||
return filtered;
|
||
}
|
||
targetId = resultIds[0];
|
||
return [targetId];
|
||
});
|
||
|
||
setFocusedDocumentId((previous) => {
|
||
if (previous && resultIds.includes(previous)) {
|
||
targetId = previous;
|
||
return previous;
|
||
}
|
||
return targetId;
|
||
});
|
||
|
||
selectionAnchorRef.current = targetId;
|
||
|
||
if (!cancelled && targetId) {
|
||
const cached = documentDetailsRef.current.get(targetId);
|
||
await ensureDocumentDetail(targetId, { force: !cached });
|
||
}
|
||
} catch (error) {
|
||
if (cancelled) return;
|
||
console.error(error);
|
||
setStatusMessage('Search failed. Please try again.', 'error');
|
||
setSearchResults(null);
|
||
} finally {
|
||
if (!cancelled && started) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
}, 300);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
clearTimeout(debounce);
|
||
if (started) {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
}, [
|
||
token,
|
||
isFilterActive,
|
||
searchQuery,
|
||
activeTagFilters,
|
||
selectedFolder,
|
||
ensureDocumentDetail,
|
||
setStatusMessage,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!token) {
|
||
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
||
dragCounterRef.current = 0;
|
||
return undefined;
|
||
}
|
||
|
||
const handleDragEnter = (event) => {
|
||
if (!hasFiles(event)) return;
|
||
event.preventDefault();
|
||
dragCounterRef.current += 1;
|
||
setDropOverlayState({ active: true, folderName: currentFolderName });
|
||
};
|
||
|
||
const handleDragOver = (event) => {
|
||
if (!hasFiles(event)) return;
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = 'copy';
|
||
};
|
||
|
||
const handleDragLeave = (event) => {
|
||
if (!hasFiles(event)) return;
|
||
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
||
if (dragCounterRef.current === 0) {
|
||
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
||
}
|
||
};
|
||
|
||
const handleDrop = async (event) => {
|
||
if (!hasFiles(event)) return;
|
||
event.preventDefault();
|
||
dragCounterRef.current = 0;
|
||
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
||
await handleFileDrop(event.dataTransfer, selectedFolder);
|
||
};
|
||
|
||
window.addEventListener('dragenter', handleDragEnter);
|
||
window.addEventListener('dragover', handleDragOver);
|
||
window.addEventListener('dragleave', handleDragLeave);
|
||
window.addEventListener('drop', handleDrop);
|
||
|
||
return () => {
|
||
window.removeEventListener('dragenter', handleDragEnter);
|
||
window.removeEventListener('dragover', handleDragOver);
|
||
window.removeEventListener('dragleave', handleDragLeave);
|
||
window.removeEventListener('drop', handleDrop);
|
||
dragCounterRef.current = 0;
|
||
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
||
};
|
||
}, [token, handleFileDrop, currentFolderName, selectedFolder]);
|
||
|
||
const handleLogin = useCallback(
|
||
async (event) => {
|
||
event.preventDefault();
|
||
const form = new FormData(event.currentTarget);
|
||
const payload = {
|
||
username: form.get('username')?.toString().trim(),
|
||
password: form.get('password')?.toString() || '',
|
||
};
|
||
if (!payload.username || !payload.password) {
|
||
setStatusMessage('Username and password are required.', 'error');
|
||
return;
|
||
}
|
||
try {
|
||
setLoading(true);
|
||
const { data } = await api.post('/auth/login', payload);
|
||
setToken(data.access_token);
|
||
setStatusMessage('Login successful.', 'success');
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatusMessage('Login failed. Check credentials.', 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[setStatusMessage],
|
||
);
|
||
|
||
const handleLogout = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
await api.post('/auth/logout');
|
||
} catch (error) {
|
||
console.warn('[Auth] Failed to revoke refresh token during logout', error);
|
||
} finally {
|
||
setLoading(false);
|
||
setToken('');
|
||
setStatusMessage('Logged out.', 'info');
|
||
setFolderNodes(new Map([[createRootNode().id, createRootNode()]]));
|
||
setFolderContents(new Map());
|
||
setSelectedFolder('root');
|
||
setCurrentFolder(null);
|
||
setCurrentSubfolders([]);
|
||
setDocuments([]);
|
||
setSelectedDocumentIds([]);
|
||
setFocusedDocumentId(null);
|
||
selectionAnchorRef.current = null;
|
||
setDraggedDocumentIds([]);
|
||
setDocumentDetails(new Map());
|
||
setSearchResults(null);
|
||
setTags([]);
|
||
setSearchQuery('');
|
||
setActiveTagFilters([]);
|
||
initializedRef.current = false;
|
||
}
|
||
}, [setStatusMessage]);
|
||
|
||
const handleBulkReanalyze = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const { data } = await api.post('/documents/reanalyze');
|
||
const total = data?.queued ?? 0;
|
||
const suffix = total === 1 ? '' : 's';
|
||
setStatusMessage(
|
||
`Queued re-analysis for ${total} document${suffix}.`,
|
||
'success',
|
||
);
|
||
} catch (error) {
|
||
console.error(error);
|
||
const message =
|
||
error.response?.data?.error || 'Failed to queue document re-analysis.';
|
||
setStatusMessage(message, 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [setStatusMessage]);
|
||
|
||
|
||
const folderClickHandlers = {
|
||
onToggle: async (folderId) => {
|
||
const node = folderNodes.get(folderId);
|
||
if (node && !node.loaded) {
|
||
try {
|
||
await ensureFolderData(folderId);
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatusMessage('Failed to load folder.', 'error');
|
||
}
|
||
}
|
||
setFolderNodes((prev) => {
|
||
const next = new Map(prev);
|
||
const current = next.get(folderId);
|
||
if (!current) return prev;
|
||
next.set(folderId, { ...current, expanded: !current.expanded });
|
||
return next;
|
||
});
|
||
},
|
||
onSelect: selectFolder,
|
||
onDrop: async (event, folderId) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
event.currentTarget.classList.remove('is-drop-target');
|
||
|
||
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
|
||
) {}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
let docIds = [];
|
||
try {
|
||
const raw = event.dataTransfer.getData('application/x-paperless-doc-list');
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (Array.isArray(parsed)) {
|
||
docIds = parsed.filter(Boolean);
|
||
}
|
||
}
|
||
} catch (
|
||
// eslint-disable-next-line no-empty
|
||
error
|
||
) {}
|
||
|
||
if (!docIds.length) {
|
||
try {
|
||
const single = event.dataTransfer.getData('application/x-paperless-doc');
|
||
if (single) {
|
||
docIds = [single];
|
||
}
|
||
} catch (
|
||
// eslint-disable-next-line no-empty
|
||
error
|
||
) {}
|
||
}
|
||
|
||
if (!docIds.length && draggedDocumentIds.length) {
|
||
docIds = draggedDocumentIds;
|
||
}
|
||
|
||
if (!docIds.length || folderId === selectedFolder) {
|
||
return;
|
||
}
|
||
|
||
setDraggedDocumentIds([]);
|
||
await moveDocumentsToFolder(docIds, folderId);
|
||
},
|
||
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 (draggedDocumentIds.length || folderDragActive) {
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = 'move';
|
||
event.currentTarget.classList.add('is-drop-target');
|
||
}
|
||
},
|
||
onDragLeave: (event) => {
|
||
event.currentTarget.classList.remove('is-drop-target');
|
||
},
|
||
};
|
||
|
||
const filterBar = (
|
||
<FilterBar
|
||
query={searchQuery}
|
||
onQueryChange={setSearchQuery}
|
||
tags={tags}
|
||
activeTagIds={activeTagFilters}
|
||
onToggleTag={(tagId) =>
|
||
setActiveTagFilters((prev) =>
|
||
prev.includes(tagId)
|
||
? prev.filter((id) => id !== tagId)
|
||
: prev.concat([tagId]),
|
||
)
|
||
}
|
||
onClear={() => {
|
||
setSearchQuery('');
|
||
setActiveTagFilters([]);
|
||
}}
|
||
hasFilters={isFilterActive}
|
||
/>
|
||
);
|
||
|
||
const defaultMoveTarget = useMemo(() => {
|
||
return folderOptions.some((option) => option.id === selectedFolder)
|
||
? selectedFolder
|
||
: 'root';
|
||
}, [folderOptions, selectedFolder]);
|
||
|
||
useEffect(() => {
|
||
if (!focusedDocumentId) return;
|
||
ensureDocumentDetail(focusedDocumentId, { force: false }).catch(() => {});
|
||
}, [focusedDocumentId, ensureDocumentDetail]);
|
||
|
||
const selectedDocument = useMemo(() => {
|
||
if (!focusedDocumentId) {
|
||
return null;
|
||
}
|
||
const list = searchResults ?? documents;
|
||
return list.find((doc) => doc.id === focusedDocumentId) || null;
|
||
}, [searchResults, documents, focusedDocumentId]);
|
||
|
||
const selectedDetail = selectedDocument
|
||
? documentDetails.get(selectedDocument.id)
|
||
: null;
|
||
|
||
const documentLookup = useMemo(() => {
|
||
const map = new Map();
|
||
documents.forEach((doc) => {
|
||
if (doc?.id) {
|
||
map.set(doc.id, doc);
|
||
}
|
||
});
|
||
if (Array.isArray(searchResults)) {
|
||
searchResults.forEach((doc) => {
|
||
if (doc?.id) {
|
||
map.set(doc.id, doc);
|
||
}
|
||
});
|
||
}
|
||
return map;
|
||
}, [documents, searchResults]);
|
||
|
||
const orderedSelectedDocuments = useMemo(() => {
|
||
const ordered = [];
|
||
const seen = new Set();
|
||
const pushDoc = (doc) => {
|
||
if (doc?.id && !seen.has(doc.id)) {
|
||
ordered.push(doc);
|
||
seen.add(doc.id);
|
||
}
|
||
};
|
||
|
||
selectionOrder.forEach((id) => {
|
||
const doc = documentLookup.get(id) || documentDetails.get(id)?.document || null;
|
||
pushDoc(doc);
|
||
});
|
||
|
||
selectedDocumentIds.forEach((id) => {
|
||
if (seen.has(id)) return;
|
||
const doc = documentLookup.get(id) || documentDetails.get(id)?.document || null;
|
||
pushDoc(doc);
|
||
});
|
||
|
||
return ordered;
|
||
}, [selectionOrder, documentLookup, selectedDocumentIds, documentDetails]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedDocumentIds.length) return;
|
||
const selectedSet = new Set(selectedDocumentIds);
|
||
const targetIds = [];
|
||
|
||
for (let index = selectionOrder.length - 1; index >= 0; index -= 1) {
|
||
const id = selectionOrder[index];
|
||
if (selectedSet.has(id) && !targetIds.includes(id)) {
|
||
targetIds.push(id);
|
||
}
|
||
if (targetIds.length >= MAX_PREVIEW_STACK_ITEMS) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!targetIds.length) {
|
||
for (let index = selectedDocumentIds.length - 1; index >= 0; index -= 1) {
|
||
const id = selectedDocumentIds[index];
|
||
if (!targetIds.includes(id)) {
|
||
targetIds.push(id);
|
||
}
|
||
if (targetIds.length >= MAX_PREVIEW_STACK_ITEMS) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
targetIds.forEach((id) => {
|
||
ensureDocumentDetail(id, { force: false }).catch(() => {});
|
||
});
|
||
}, [selectionOrder, selectedDocumentIds, ensureDocumentDetail]);
|
||
|
||
const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => {
|
||
const chain = [];
|
||
const seen = new Set();
|
||
const pending = new Set();
|
||
let currentId = selectedFolder || 'root';
|
||
let guard = 0;
|
||
|
||
while (currentId && !seen.has(currentId) && guard < 32) {
|
||
guard += 1;
|
||
seen.add(currentId);
|
||
|
||
if (currentId === 'root') {
|
||
chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
||
currentId = null;
|
||
break;
|
||
}
|
||
|
||
const node = folderNodes.get(currentId);
|
||
if (node) {
|
||
chain.push({ id: currentId, name: node.name || 'Folder' });
|
||
currentId = node.parentId ?? 'root';
|
||
continue;
|
||
}
|
||
|
||
let fallbackName = '…';
|
||
let parentId = null;
|
||
|
||
if (currentFolder && currentFolder.id === currentId) {
|
||
fallbackName = currentFolder.name;
|
||
parentId = currentFolder.parent_id ?? 'root';
|
||
}
|
||
|
||
chain.push({ id: currentId, name: fallbackName });
|
||
pending.add(currentId);
|
||
currentId = parentId;
|
||
}
|
||
|
||
if (!chain.some((crumb) => crumb.id === 'root')) {
|
||
chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
||
}
|
||
|
||
const ordered = [];
|
||
const seenOrdered = new Set();
|
||
chain
|
||
.slice()
|
||
.reverse()
|
||
.forEach((crumb) => {
|
||
if (!seenOrdered.has(crumb.id)) {
|
||
seenOrdered.add(crumb.id);
|
||
ordered.push(crumb);
|
||
}
|
||
});
|
||
|
||
return { breadcrumbs: ordered, missingBreadcrumbAncestors: Array.from(pending) };
|
||
}, [selectedFolder, folderNodes, currentFolder]);
|
||
|
||
useEffect(() => {
|
||
if (!missingBreadcrumbAncestors.length) {
|
||
return;
|
||
}
|
||
|
||
missingBreadcrumbAncestors.forEach((folderId) => {
|
||
if (!folderId || folderId === 'root') {
|
||
return;
|
||
}
|
||
if (breadcrumbFetchRef.current.has(folderId)) {
|
||
return;
|
||
}
|
||
|
||
breadcrumbFetchRef.current.add(folderId);
|
||
ensureFolderData(folderId, { force: false })
|
||
.catch((error) => {
|
||
console.warn('Failed to preload breadcrumb ancestor', folderId, error);
|
||
})
|
||
.finally(() => {
|
||
breadcrumbFetchRef.current.delete(folderId);
|
||
});
|
||
});
|
||
}, [missingBreadcrumbAncestors, ensureFolderData]);
|
||
|
||
const selectedPreviewEntry = useMemo(() => {
|
||
if (!selectedDocument) {
|
||
return null;
|
||
}
|
||
return previewCacheRef.current.get(selectedDocument.id) || null;
|
||
}, [selectedDocument, previewCacheTick]);
|
||
|
||
const previewWorkspaceEntry = useMemo(() => {
|
||
if (!previewDocumentId) {
|
||
return null;
|
||
}
|
||
return previewCacheRef.current.get(previewDocumentId) || null;
|
||
}, [previewDocumentId, previewCacheTick]);
|
||
|
||
const previewWorkspaceDetail = previewDocumentId
|
||
? documentDetails.get(previewDocumentId)
|
||
: null;
|
||
|
||
const previewWorkspaceDocument = useMemo(() => {
|
||
if (!previewDocumentId) return null;
|
||
if (previewWorkspaceDetail?.document) return previewWorkspaceDetail.document;
|
||
const pool = searchResults ?? documents;
|
||
return pool.find((doc) => doc.id === previewDocumentId) || null;
|
||
}, [previewDocumentId, previewWorkspaceDetail, searchResults, documents]);
|
||
|
||
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
|
||
|
||
const sidebarProps = {
|
||
folderNodes,
|
||
onToggle: folderClickHandlers.onToggle,
|
||
onSelect: folderClickHandlers.onSelect,
|
||
onDrop: folderClickHandlers.onDrop,
|
||
onDragOver: folderClickHandlers.onDragOver,
|
||
onDragLeave: folderClickHandlers.onDragLeave,
|
||
onCreateFolder: handleFolderCreate,
|
||
onDeleteFolder: handleFolderDelete,
|
||
selectedFolder,
|
||
onFolderDragStart: handleFolderDragStart,
|
||
onFolderDragEnd: handleFolderDragEnd,
|
||
draggedFolderId,
|
||
onShowTags: () => navigate('/tags'),
|
||
tags,
|
||
};
|
||
|
||
const documentsTableProps = {
|
||
currentFolderName,
|
||
breadcrumbs,
|
||
onRefresh: refreshCurrentFolder,
|
||
subfolders: currentSubfolders,
|
||
documents,
|
||
searchResults,
|
||
isFilterActive,
|
||
onFolderSelect: selectFolder,
|
||
onFolderDrop: folderClickHandlers.onDrop,
|
||
onFolderDragOver: folderClickHandlers.onDragOver,
|
||
onFolderDragLeave: folderClickHandlers.onDragLeave,
|
||
onFolderDragStart: handleFolderDragStart,
|
||
onFolderDragEnd: handleFolderDragEnd,
|
||
draggedFolderId,
|
||
onFolderDelete: handleFolderDelete,
|
||
onDocumentRowClick: handleDocumentRowClick,
|
||
onDocumentOpen: openDocumentPreview,
|
||
selectedDocumentIds,
|
||
focusedDocumentId,
|
||
draggingDocumentIds: draggedDocumentIds,
|
||
onDocumentDragStart: handleDocumentDragStart,
|
||
onDocumentDragEnd: handleDocumentDragEnd,
|
||
filterBar,
|
||
tagLookupById,
|
||
};
|
||
|
||
const detailPanelProps = {
|
||
selectedDocuments: orderedSelectedDocuments,
|
||
detailMap: documentDetails,
|
||
tags,
|
||
tagLookupById,
|
||
tagLookupByLabel,
|
||
onTagAdd: handleTagAdd,
|
||
onTagRemove: handleTagRemove,
|
||
onRegenerateThumbnails: handleThumbnailRegeneration,
|
||
previewEntry: selectedPreviewEntry,
|
||
onOpenPreview: openDocumentPreview,
|
||
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||
onBulkMove: handleBulkMoveFromDetail,
|
||
onBulkReanalyze: handleBulkSelectionReanalyze,
|
||
folderOptions,
|
||
defaultMoveTarget,
|
||
onPromoteSelection: promoteSelectionOrder,
|
||
activePreviewId,
|
||
};
|
||
|
||
const contextValue = useMemo(
|
||
() => ({
|
||
token,
|
||
status,
|
||
dropOverlayState,
|
||
handleBulkReanalyze,
|
||
handleLogout,
|
||
sidebarProps,
|
||
tags,
|
||
refreshTags,
|
||
handleTagUpdate,
|
||
previewActive,
|
||
previewWorkspaceDocument,
|
||
previewWorkspaceDetail,
|
||
previewWorkspaceEntry,
|
||
closeDocumentPreview,
|
||
handleThumbnailRegeneration,
|
||
documentsTableProps,
|
||
detailPanelProps,
|
||
}),
|
||
[
|
||
token,
|
||
status,
|
||
dropOverlayState,
|
||
handleBulkReanalyze,
|
||
handleLogout,
|
||
sidebarProps,
|
||
tags,
|
||
refreshTags,
|
||
handleTagUpdate,
|
||
previewActive,
|
||
previewWorkspaceDocument,
|
||
previewWorkspaceDetail,
|
||
previewWorkspaceEntry,
|
||
closeDocumentPreview,
|
||
handleThumbnailRegeneration,
|
||
documentsTableProps,
|
||
detailPanelProps,
|
||
],
|
||
);
|
||
|
||
if (!token) {
|
||
return (
|
||
<div className="app-shell">
|
||
<LoginView onSubmit={handleLogin} status={status} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<AppShellContext.Provider value={contextValue}>
|
||
<div className="app-shell">
|
||
<DropOverlay
|
||
active={dropOverlayState.active}
|
||
folderName={dropOverlayState.folderName}
|
||
/>
|
||
<header className="app-bar">
|
||
<div className="app-bar__main">
|
||
<div className="app-bar__meta">
|
||
<h1>Paperless-NEO</h1>
|
||
<span className="app-bar__hint">
|
||
{previewActive
|
||
? 'Viewing document preview. Press ← Back to return to the library.'
|
||
: 'Drag files here to upload.'}
|
||
</span>
|
||
</div>
|
||
{status && (
|
||
<div className="app-bar__status">
|
||
<StatusBanner status={status} />
|
||
</div>
|
||
)}
|
||
<div className="app-bar__actions">
|
||
<button
|
||
className="secondary"
|
||
type="button"
|
||
onClick={handleBulkReanalyze}
|
||
>
|
||
Re-analyze All
|
||
</button>
|
||
<button className="secondary" onClick={handleLogout}>
|
||
Log out
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
<Outlet />
|
||
</div>
|
||
</AppShellContext.Provider>
|
||
);
|
||
};
|
||
|
||
const useAppShell = () => {
|
||
const context = useContext(AppShellContext);
|
||
if (!context) {
|
||
throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.');
|
||
}
|
||
return context;
|
||
};
|
||
|
||
const DocumentsRoute = () => {
|
||
const {
|
||
sidebarProps,
|
||
previewActive,
|
||
previewWorkspaceDocument,
|
||
previewWorkspaceDetail,
|
||
previewWorkspaceEntry,
|
||
closeDocumentPreview,
|
||
handleThumbnailRegeneration,
|
||
documentsTableProps,
|
||
detailPanelProps,
|
||
} = useAppShell();
|
||
|
||
if (previewActive && previewWorkspaceDocument) {
|
||
return (
|
||
<main className="preview-main">
|
||
<PreviewWorkspace
|
||
document={previewWorkspaceDocument}
|
||
detail={previewWorkspaceDetail}
|
||
previewEntry={previewWorkspaceEntry}
|
||
onClose={closeDocumentPreview}
|
||
onRegenerateThumbnails={handleThumbnailRegeneration}
|
||
/>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<MainLayout sidebarProps={sidebarProps}>
|
||
<DocumentsTable {...documentsTableProps} />
|
||
<DetailPanel {...detailPanelProps} />
|
||
</MainLayout>
|
||
);
|
||
};
|
||
|
||
const TagsRoute = () => {
|
||
const { sidebarProps, tags, refreshTags, handleTagUpdate } = useAppShell();
|
||
return (
|
||
<MainLayout sidebarProps={sidebarProps} className="tags-main">
|
||
<TagsPanel tags={tags} onRefresh={refreshTags} onUpdateTag={handleTagUpdate} />
|
||
</MainLayout>
|
||
);
|
||
};
|
||
|
||
const AppRouter = () => (
|
||
<Routes>
|
||
<Route element={<AppLayout />}>
|
||
<Route path="/" element={<Navigate to="/folders" replace />} />
|
||
<Route path="/folders" element={<DocumentsRoute />} />
|
||
<Route path="/folders/:folderId" element={<DocumentsRoute />} />
|
||
<Route path="/documents/:documentId" element={<DocumentsRoute />} />
|
||
<Route
|
||
path="/folders/:folderId/documents/:documentId"
|
||
element={<DocumentsRoute />}
|
||
/>
|
||
<Route path="/tags" element={<TagsRoute />} />
|
||
<Route path="*" element={<Navigate to="/folders" replace />} />
|
||
</Route>
|
||
</Routes>
|
||
);
|
||
|
||
const container = document.getElementById('app');
|
||
const root = createRoot(container);
|
||
root.render(
|
||
<HashRouter hashType="hashbang">
|
||
<AppRouter />
|
||
</HashRouter>,
|
||
);
|