import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createRoot } from 'react-dom/client';
import axios from 'axios';
import {
HashRouter,
Navigate,
Route,
Routes,
useNavigate,
useParams,
} from 'react-router-dom';
import './styles.css';
const API_ROOT = (process.env.API_BASE_URL || '').replace(/\/$/, '');
const api = axios.create({
baseURL: API_ROOT ? `${API_ROOT}/api` : '/api',
withCredentials: true,
});
const DEFAULT_FOLDER_NAME = 'All Documents';
const hasFiles = (event) =>
Array.from(event.dataTransfer?.types || []).includes('Files');
const IconChevronRight = ({ className }) => (
);
const IconTrash = ({ className }) => (
);
const IconDownload = ({ className }) => (
);
const IconFolder = ({ className }) => (
);
const createRootNode = () => ({
id: 'root',
name: DEFAULT_FOLDER_NAME,
parentId: null,
children: [],
expanded: true,
loaded: false,
});
const StatusBanner = ({ status }) => {
if (!status) return null;
return
{status.message}
;
};
const DropOverlay = ({ active, folderName }) => (
Drop files to upload to {folderName}
);
const LoginView = ({ onSubmit, status }) => (
Paperless-NEO
Authenticate to manage your documents.
);
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 ? : null;
const canDrag = node.id !== 'root';
const isDragging = draggingFolderId === node.id;
const handleToggleClick = (event) => {
event.stopPropagation();
if (canToggle) {
onToggle(node.id);
}
};
return (
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);
}
}}
>
{icon}
{node.name}
{node.id !== 'root' && (
)}
{node.expanded && node.children.length > 0 && (
{renderChildren(node.children, depth + 1)}
)}
);
};
const FilterBar = ({
query,
onQueryChange,
tags,
activeTagIds,
onToggleTag,
onClear,
hasFilters,
}) => (
onQueryChange(event.target.value)}
/>
{tags.length ? (
tags.map((tag) => (
))
) : (
No tags yet
)}
{hasFilters && (
)}
);
const DocumentsTable = ({
currentFolderName,
breadcrumbs,
onRefresh,
subfolders,
documents,
searchResults,
isFilterActive,
onFolderSelect,
onFolderDrop,
onFolderDragOver,
onFolderDragLeave,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
onFolderDelete,
onDocumentRowClick,
onDocumentOpen,
selectedDocumentIds,
focusedDocumentId,
draggingDocumentIds = [],
onDocumentDragStart,
onDocumentDragEnd,
onDownload,
filterBar,
}) => {
const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents;
const selectedSet = useMemo(
() => new Set(selectedDocumentIds),
[selectedDocumentIds],
);
const draggingSet = useMemo(
() => new Set(draggingDocumentIds || []),
[draggingDocumentIds],
);
return (
{filterBar}
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
Drop files anywhere or onto a folder to upload documents.
) : (
| Preview |
Name |
Type |
Updated |
Actions |
{!showingSearchResults &&
subfolders.map((folder) => {
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
return (
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);
}
}}
>
|
|
{folder.name} |
Folder |
— |
— |
|
);
})}
{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 (
onDocumentRowClick(doc.id, event)}
onDoubleClick={(event) => {
event.stopPropagation();
if (onDocumentOpen) {
onDocumentOpen(doc.id);
}
}}
draggable
onDragStart={(event) => onDocumentDragStart(event, doc.id)}
onDragEnd={onDocumentDragEnd}
>
{doc.thumbnail ? (
) : (
DOC
)}
|
{doc.title || doc.original_name}
{(doc.tags || []).length > 0 && (
{(doc.tags || []).map((tag) => (
{tag.label}
))}
)}
|
{doc.content_type || 'Document'} |
{
doc.updated_at
? new Date(doc.updated_at).toLocaleString()
: '—'
} |
|
);
})}
)}
{rows.length === 0 && isFilterActive && (
No documents match the current filters.
)}
{showingSearchResults && rows.length > 0 && (
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
)}
);
};
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 {emptyMessage};
}
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 (
{preparedItems.map(({ entry, angle, offset }, index) => {
const transform = hasMultiple
? `translate(-50%, -50%) rotate(${angle}deg)`
: 'translate(-50%, -50%)';
const isFront = index === 0;
return (

{
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);
}
}
}}
/>
);
})}
);
};
const DetailPanel = ({
selectedDocuments = [],
detailMap = new Map(),
tags = [],
onDownload,
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 Select a document to view metadata, tags and actions.
;
}
if (!detail) {
return Loading details…
;
}
const displayName = singleDoc.title || singleDoc.original_name;
return (
<>
{displayName}
Uploaded:{' '}
{singleDoc.uploaded_at ? new Date(singleDoc.uploaded_at).toLocaleString() : '—'}
Size:{' '}
{detail.current_version
? `${(detail.current_version.size_bytes / 1024).toFixed(1)} KB`
: '—'}
Type: {singleDoc.content_type || 'Unknown'}
Issued:{' '}
{detail.document.issued_at
? new Date(detail.document.issued_at).toLocaleString()
: '—'}
Original filename:{' '}
{singleDoc.original_name}
Tags
{detail.document.tags?.length ? (
detail.document.tags.map((tag) => (
{tag.label}{' '}
))
) : (
No tags yet.
)}
{detail.document.metadata &&
Object.keys(detail.document.metadata).length > 0 && (
Metadata
{JSON.stringify(detail.document.metadata, null, 2)}
)}
>
);
};
const renderBulk = () => {
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
const sizeLabel = stackTotalSizeBytes
? `${(stackTotalSizeBytes / 1024).toFixed(1)} KB`
: '—';
return (
<>
{countLabel}
Total size (stack): {sizeLabel}
Common tags:{' '}
{commonTags.length ? commonTags.join(', ') : 'None'}
{commonTags.length > 0 && (
{commonTags.map((label) => (
{label}
))}
)}
>
);
};
return (
);
};
const PreviewWorkspace = ({
document,
detail,
previewEntry,
onClose,
onDownload,
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';
return (
{!previewEntry?.url ? (
Loading preview…
) : (
)}
{detail?.document?.metadata && Object.keys(detail.document.metadata).length > 0 && (
Metadata
{JSON.stringify(detail.document.metadata, null, 2)}
)}
);
};
const Sidebar = ({
folderNodes,
onToggle,
onSelect,
onDrop,
onDragOver,
onDragLeave,
onCreateFolder,
onDeleteFolder,
selectedFolder,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
}) => {
const renderNodes = useCallback(
(ids, depth) =>
ids.map((id) => {
const node = folderNodes.get(id);
if (!node) return null;
return (
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 (
);
};
function App({ routeFolderId = null, routeDocumentId = null, navigate }) {
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 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 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 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 handleDownload = useCallback(
async (documentId) => {
try {
setLoading(true);
const { data } = await api.get(`/documents/${documentId}/download`);
window.open(data.url, '_blank');
setStatusMessage('Download link opened in a new tab.', 'success');
} catch (error) {
console.error(error);
setStatusMessage('Unable to fetch download link.', 'error');
} finally {
setLoading(false);
}
},
[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 = (
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);
return (
{token ? (
<>
{previewActive && previewWorkspaceDocument ? (
) : (
<>
{
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');
}}
onDocumentDragEnd={(event) => {
setDraggedDocumentIds([]);
event.currentTarget.classList.remove('dragging');
}}
onDownload={handleDownload}
filterBar={filterBar}
/>
>
)}
>
) : (
)}
);
}
const RoutedApp = () => {
const navigate = useNavigate();
const params = useParams();
const folderId = params.folderId ?? null;
const documentId = params.documentId ?? null;
return (
);
};
const AppRouter = () => (
} />
} />
} />
} />
} />
} />
);
const container = document.getElementById('app');
const root = createRoot(container);
root.render(
,
);