patch tags

This commit is contained in:
2025-10-12 01:52:30 +02:00
parent 1b950a8f9a
commit f30e455c2d
20 changed files with 1306 additions and 161 deletions
+643 -105
View File
@@ -24,11 +24,56 @@ const api = axios.create({
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'}
@@ -292,16 +337,28 @@ const FilterBar = ({
/>
<div className="tag-filters">
{tags.length ? (
tags.map((tag) => (
<button
key={tag.id}
type="button"
className={`tag-filter${activeTagIds.includes(tag.id) ? ' active' : ''}`}
onClick={() => onToggleTag(tag.id)}
>
{tag.label}
</button>
))
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>
)}
@@ -339,8 +396,9 @@ const DocumentsTable = ({
draggingDocumentIds = [],
onDocumentDragStart,
onDocumentDragEnd,
onDownload,
onDocumentDelete,
filterBar,
tagLookupById,
}) => {
const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents;
@@ -441,7 +499,6 @@ const DocumentsTable = ({
<td>{folder.name}</td>
<td>Folder</td>
<td></td>
<td></td>
<td className="actions">
<button
type="button"
@@ -498,11 +555,20 @@ const DocumentsTable = ({
<span className="doc-name__title">{doc.title || doc.original_name}</span>
{(doc.tags || []).length > 0 && (
<div className="doc-name__tags">
{(doc.tags || []).map((tag) => (
<span key={tag.id} className="badge">
{tag.label}
</span>
))}
{(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>
@@ -514,17 +580,34 @@ const DocumentsTable = ({
: '—'
}</td>
<td className="actions">
<button
type="button"
className="with-icon"
onClick={(event) => {
event.stopPropagation();
onDownload(doc.id);
}}
>
<IconDownload className="icon-inline" />
<span>Download</span>
</button>
<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>
);
@@ -639,7 +722,8 @@ const DetailPanel = ({
selectedDocuments = [],
detailMap = new Map(),
tags = [],
onDownload,
tagLookupById = new Map(),
tagLookupByLabel = new Map(),
onTagAdd,
onTagRemove,
onRegenerateThumbnails,
@@ -746,6 +830,9 @@ const DetailPanel = ({
}
const displayName = singleDoc.title || singleDoc.original_name;
const downloadHref = singleDoc.download_path
? resolveApiPath(singleDoc.download_path)
: null;
return (
<>
@@ -788,10 +875,21 @@ const DetailPanel = ({
</div>
</div>
<div className="detail-actions">
<button type="button" className="with-icon" onClick={() => onDownload(singleDoc.id)}>
<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>
</button>
</a>
<button
type="button"
className="secondary"
@@ -811,14 +909,18 @@ const DetailPanel = ({
<dt>Tags</dt>
<div className="tag-list">
{detail.document.tags?.length ? (
detail.document.tags.map((tag) => (
<span key={tag.id} className="tag-pill">
{tag.label}{' '}
<button type="button" onClick={() => onTagRemove(singleDoc.id, tag.id)}>
×
</button>
</span>
))
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>
)}
@@ -884,11 +986,16 @@ const DetailPanel = ({
</div>
{commonTags.length > 0 && (
<div className="bulk-tags">
{commonTags.map((label) => (
<span key={label} className="tag-pill">
{label}
</span>
))}
{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">
@@ -971,7 +1078,6 @@ const PreviewWorkspace = ({
detail,
previewEntry,
onClose,
onDownload,
onRegenerateThumbnails,
}) => {
if (!document) {
@@ -984,6 +1090,9 @@ const PreviewWorkspace = ({
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">
@@ -1007,10 +1116,21 @@ const PreviewWorkspace = ({
</div>
</div>
<div className="preview-workspace__actions">
<button type="button" className="with-icon" onClick={() => onDownload(document.id)}>
<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>
</button>
</a>
<button
type="button"
className="secondary"
@@ -1122,12 +1242,279 @@ const Sidebar = ({
{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${isTagsView ? ' active' : ''}`}
onClick={onShowTags}
>
All tags
</button>
</div>
</div>
</aside>
);
};
function App({ routeFolderId = null, routeDocumentId = null, navigate }) {
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 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
className="tags-table__color-field"
value={draftColor}
onChange={(event) => setDraftColor(event.target.value)}
onKeyDown={handleKeyDown}
placeholder="#3366ff"
spellCheck="false"
disabled={saving}
/>
{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 DocumentsWorkspace = ({
sidebarProps,
previewActive,
previewWorkspaceDocument,
previewWorkspaceDetail,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
documentsTableProps,
detailPanelProps,
}) => {
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 TagsWorkspace = ({ sidebarProps, tags, onRefresh, onUpdateTag }) => (
<MainLayout sidebarProps={sidebarProps} className="tags-main">
<TagsPanel tags={tags} onRefresh={onRefresh} onUpdateTag={onUpdateTag} />
</MainLayout>
);
function App({
routeFolderId = null,
routeDocumentId = null,
navigate,
mode = 'documents',
}) {
const [token, setToken] = useState(() => window.localStorage.getItem('paperless_token') || '');
const [status, setStatus] = useState(null);
const setStatusMessage = useCallback((message, variant = 'info') => {
@@ -1186,6 +1573,26 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) {
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));
@@ -1559,6 +1966,40 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) {
],
);
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)) {
@@ -1795,6 +2236,39 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) {
}
}, [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';
@@ -2474,23 +2948,6 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) {
[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) {
@@ -3332,7 +3789,81 @@ const handleDownload = useCallback(
return pool.find((doc) => doc.id === previewDocumentId) || null;
}, [previewDocumentId, previewWorkspaceDetail, searchResults, documents]);
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
const isTagsView = mode === 'tags';
const previewActive = !isTagsView && Boolean(previewDocumentId && previewWorkspaceDocument);
const goToTagsView = useCallback(() => {
if (!navigate || isTagsView) {
return;
}
navigate('/tags');
}, [navigate, isTagsView]);
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: goToTagsView,
isTagsView,
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,
};
return (
<div className="app-shell">
@@ -3378,7 +3909,6 @@ const handleDownload = useCallback(
detail={previewWorkspaceDetail}
previewEntry={previewWorkspaceEntry}
onClose={closeDocumentPreview}
onDownload={handleDownload}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
) : (
@@ -3419,40 +3949,38 @@ const handleDownload = useCallback(
focusedDocumentId={focusedDocumentId}
draggingDocumentIds={draggedDocumentIds}
onDocumentDragStart={(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');
}}
onDocumentDragEnd={(event) => {
setDraggedDocumentIds([]);
event.currentTarget.classList.remove('dragging');
}}
onDownload={handleDownload}
filterBar={filterBar}
/>
<DetailPanel
selectedDocuments={orderedSelectedDocuments}
detailMap={documentDetails}
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');
}}
filterBar={filterBar}
/>
<DetailPanel
selectedDocuments={orderedSelectedDocuments}
detailMap={documentDetails}
tags={tags}
onDownload={handleDownload}
onTagAdd={handleTagAdd}
onTagRemove={handleTagRemove}
onRegenerateThumbnails={handleThumbnailRegeneration}
@@ -3478,7 +4006,7 @@ const handleDownload = useCallback(
);
}
const RoutedApp = () => {
const RoutedDocumentsApp = () => {
const navigate = useNavigate();
const params = useParams();
const folderId = params.folderId ?? null;
@@ -3486,6 +4014,7 @@ const RoutedApp = () => {
return (
<App
mode="documents"
routeFolderId={folderId}
routeDocumentId={documentId}
navigate={navigate}
@@ -3493,13 +4022,22 @@ const RoutedApp = () => {
);
};
const RoutedTagsApp = () => {
const navigate = useNavigate();
return <App mode="tags" navigate={navigate} />;
};
const AppRouter = () => (
<Routes>
<Route path="/" element={<Navigate to="/folders" replace />} />
<Route path="/folders" element={<RoutedApp />} />
<Route path="/folders/:folderId" element={<RoutedApp />} />
<Route path="/documents/:documentId" element={<RoutedApp />} />
<Route path="/folders/:folderId/documents/:documentId" element={<RoutedApp />} />
<Route path="/folders" element={<RoutedDocumentsApp />} />
<Route path="/folders/:folderId" element={<RoutedDocumentsApp />} />
<Route path="/documents/:documentId" element={<RoutedDocumentsApp />} />
<Route
path="/folders/:folderId/documents/:documentId"
element={<RoutedDocumentsApp />}
/>
<Route path="/tags" element={<RoutedTagsApp />} />
<Route path="*" element={<Navigate to="/folders" replace />} />
</Routes>
);