patch tags
This commit is contained in:
+643
-105
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user