Files
papercrate/frontend/src/index.jsx
T
2025-10-12 01:59:26 +02:00

4012 lines
121 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>,
);