Initial commit
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 21s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 23s

This commit is contained in:
2025-10-17 20:19:47 +02:00
commit 2af2af3460
132 changed files with 35408 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
export const getAssetFromGroup = (assets, assetType) => {
if (!assetType || !assets) {
return null;
}
if (Array.isArray(assets)) {
return assets.find((entry) => entry?.asset_type === assetType) || null;
}
return assets?.[assetType] || null;
};
export const getAssetFromVersion = (currentVersion, assetType) => {
if (!currentVersion) {
return null;
}
return getAssetFromGroup(currentVersion.assets, assetType);
};
export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, ensureOptions } = {}) => {
if (!doc || !type) {
return null;
}
const asset = typeof getAsset === 'function' ? getAsset(doc, type) : null;
if (!asset) {
return null;
}
const now = Date.now();
const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
const hasFreshUrl = asset.url && (!expiresAt || expiresAt > now);
if (hasFreshUrl) {
return asset.url;
}
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
const force = Boolean(asset.url && expiresAt && expiresAt <= now);
const options = ensureOptions ? { ...ensureOptions, force } : { force };
ensureAssetUrl(doc.id, asset, options).catch(() => {});
}
return null;
};
class AssetManager {
constructor({ api, assetPresignTtlMs }) {
this.api = api;
this.assetPresignTtlMs = assetPresignTtlMs;
this.assetCache = new Map();
this.assetInflight = new Map();
}
setApi(api) {
this.api = api;
}
rememberAsset(entry) {
if (entry?.id) {
this.assetCache.set(entry.id, entry);
}
}
hydrateAsset(asset) {
if (!asset || !asset.id) {
return asset;
}
const cached = this.assetCache.get(asset.id);
if (!cached) {
return asset;
}
const merged = { ...cached, ...asset };
if (cached.url && !asset.url) {
merged.url = cached.url;
}
if (cached.expiresAt) {
const cachedExpires = Number(cached.expiresAt) || null;
const assetExpires = Number(asset.expiresAt) || null;
if (!assetExpires || (cachedExpires && cachedExpires > assetExpires)) {
merged.expiresAt = cachedExpires;
}
}
return merged;
}
hydrateDocument(document) {
if (!document) {
return document;
}
const currentVersion = document.current_version || null;
if (!currentVersion) {
return document;
}
let changed = false;
let nextAssets = currentVersion.assets;
if (nextAssets && !Array.isArray(nextAssets)) {
const hydrated = {};
Object.keys(nextAssets).forEach((key) => {
hydrated[key] = this.hydrateAsset(nextAssets[key]);
if (hydrated[key] !== nextAssets[key]) {
changed = true;
}
});
if (changed) {
nextAssets = { ...nextAssets, ...hydrated };
}
} else if (Array.isArray(nextAssets)) {
const hydratedList = nextAssets.map((item) => this.hydrateAsset(item));
if (
hydratedList.length !== nextAssets.length ||
hydratedList.some((item, index) => item !== nextAssets[index])
) {
changed = true;
nextAssets = hydratedList;
}
}
if (!changed) {
return document;
}
const nextCurrentVersion = { ...currentVersion, assets: nextAssets };
return { ...document, current_version: nextCurrentVersion };
}
hydrateDocuments(documents) {
if (!Array.isArray(documents)) {
return documents;
}
return documents.map((doc) => this.hydrateDocument(doc));
}
hydrateDetail(detail) {
if (!detail) {
return detail;
}
let changed = false;
const next = { ...detail };
if (detail.document) {
const hydratedDocument = this.hydrateDocument(detail.document);
if (hydratedDocument !== detail.document) {
next.document = hydratedDocument;
changed = true;
}
}
if (Array.isArray(detail.assets)) {
const hydratedAssets = detail.assets.map((item) => this.hydrateAsset(item));
if (
hydratedAssets.length !== detail.assets.length ||
hydratedAssets.some((item, index) => item !== detail.assets[index])
) {
next.assets = hydratedAssets;
changed = true;
}
}
return changed ? next : detail;
}
hydrateFolderContents(contents) {
if (!contents) {
return contents;
}
const next = { ...contents };
if (Array.isArray(contents.documents)) {
next.documents = this.hydrateDocuments(contents.documents);
}
if (contents.document) {
next.document = this.hydrateDocument(contents.document);
}
return next;
}
ensureAsset(documentId, asset, { force = false } = {}) {
if (!documentId || !asset?.id) {
return Promise.resolve(asset || null);
}
const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) {
this.rememberAsset(asset);
return Promise.resolve(asset);
}
const cached = this.assetCache.get(asset.id);
const now = Date.now();
if (!force && cached && cached.expiresAt && cached.expiresAt > now && cached.url) {
return Promise.resolve({ ...asset, ...cached });
}
const inflightKey = `${documentId}:${asset.id}`;
if (!force && this.assetInflight.has(inflightKey)) {
return this.assetInflight.get(inflightKey);
}
if (!this.api) {
return Promise.reject(new Error('AssetManager API client is not configured.'));
}
const request = this.api
.get(`/documents/${documentId}/assets/${asset.id}`)
.then(({ data }) => {
const entry = {
...asset,
...data,
expiresAt: Date.now() + this.assetPresignTtlMs,
};
this.rememberAsset(entry);
return entry;
})
.finally(() => {
this.assetInflight.delete(inflightKey);
});
this.assetInflight.set(inflightKey, request);
return request;
}
reset() {
this.assetCache.clear();
this.assetInflight.clear();
}
}
export default AssetManager;
+3
View File
@@ -0,0 +1,3 @@
export const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
export default CORRESPONDENT_ROLES;
@@ -0,0 +1,240 @@
import React, { useCallback, useState } from 'react';
function CorrespondentsPanel({
correspondents = [],
onRefresh,
onCreate,
onUpdate,
onDelete,
onNotify,
}) {
const [editingId, setEditingId] = useState(null);
const [draftName, setDraftName] = useState('');
const [createName, setCreateName] = useState('');
const [saving, setSaving] = useState(false);
const [creating, setCreating] = useState(false);
const [deletingId, setDeletingId] = useState(null);
const startEdit = useCallback((correspondent) => {
setEditingId(correspondent.id);
setDraftName(correspondent.name || '');
}, []);
const cancelEdit = useCallback(() => {
setEditingId(null);
setDraftName('');
setSaving(false);
}, []);
const handleSave = useCallback(async () => {
if (!editingId) return;
const trimmed = draftName.trim();
if (!trimmed) {
onNotify?.('Correspondent name cannot be empty.', 'error');
return;
}
setSaving(true);
try {
await onUpdate(editingId, { name: trimmed });
cancelEdit();
} catch (
// eslint-disable-next-line no-empty
error
) {}
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
const handleDelete = useCallback(
async (correspondent) => {
if (!correspondent?.id) return;
setDeletingId(correspondent.id);
try {
await onDelete(correspondent.id);
if (editingId === correspondent.id) {
cancelEdit();
}
} catch (
// eslint-disable-next-line no-empty
error
) {
return;
} finally {
setDeletingId(null);
}
},
[onDelete, editingId, cancelEdit],
);
const handleCreate = useCallback(
async (event) => {
event.preventDefault();
const trimmed = createName.trim();
if (!trimmed) {
onNotify?.('Correspondent name cannot be empty.', 'error');
return;
}
setCreating(true);
try {
await onCreate({ name: trimmed });
setCreateName('');
} catch (
// eslint-disable-next-line no-empty
error
) {
return;
} finally {
setCreating(false);
}
},
[createName, onCreate, onNotify],
);
const handleKeyDown = useCallback(
(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleSave();
} else if (event.key === 'Escape') {
event.preventDefault();
cancelEdit();
}
},
[handleSave, cancelEdit],
);
const renderUsage = useCallback((usage) => {
if (!usage) {
return '0';
}
const total = typeof usage.total === 'number' ? usage.total : 0;
const entries = usage.by_role ? Object.entries(usage.by_role) : [];
if (!entries.length) {
return total.toString();
}
const roleSummary = entries
.map(([role, count]) => `${role}: ${count}`)
.join(', ');
return `${total} (${roleSummary})`;
}, []);
return (
<section className="correspondents-panel column">
<div className="column-header">
<div className="column-header__titles">
<h2>Correspondents</h2>
<div className="column-subtitle">{correspondents.length} total</div>
</div>
<div className="header-actions correspondents-actions">
<form className="correspondents-actions__form" onSubmit={handleCreate}>
<input
type="text"
placeholder="New correspondent name"
value={createName}
onChange={(event) => setCreateName(event.target.value)}
disabled={creating}
/>
<button type="submit" disabled={creating || !createName.trim()}>
{creating ? 'Creating…' : 'Create'}
</button>
</form>
<button
className="secondary"
type="button"
onClick={onRefresh}
disabled={saving || creating || Boolean(deletingId)}
>
Refresh
</button>
</div>
</div>
<div className="column-body tags-panel__body">
{correspondents.length === 0 ? (
<div className="empty-state">No correspondents created yet.</div>
) : (
<div className="tags-table">
<table>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col" className="numeric">
Usage
</th>
<th scope="col" className="actions">
Actions
</th>
</tr>
</thead>
<tbody>
{correspondents.map((correspondent) => {
const isEditing = editingId === correspondent.id;
return (
<tr key={correspondent.id} className={isEditing ? 'editing' : ''}>
<td className="tags-table__label">
{isEditing ? (
<input
className="tags-table__label-input"
value={draftName}
onChange={(event) => setDraftName(event.target.value)}
onKeyDown={handleKeyDown}
disabled={saving}
autoFocus
/>
) : (
<span>{correspondent.name}</span>
)}
</td>
<td className="numeric">{renderUsage(correspondent.usage)}</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>
) : (
<div className="tags-table__row-actions">
<button
type="button"
className="secondary"
onClick={() => startEdit(correspondent)}
disabled={deletingId === correspondent.id}
>
Edit
</button>
<button
type="button"
className="danger"
onClick={() => handleDelete(correspondent)}
disabled={deletingId === correspondent.id}
>
Delete
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</section>
);
}
export default CorrespondentsPanel;
+926
View File
@@ -0,0 +1,926 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { DownloadIcon, EditIcon } from '../ui/icons';
import { getTagColorStyle } from '../utils/colors';
import { formatFileSize } from '../utils/format';
import { resolveDocumentAssetUrl } from '../asset_manager';
import { CORRESPONDENT_ROLES } from '../constants/correspondents';
const MAX_PREVIEW_STACK_ITEMS = 15;
const normalizeRole = (role) => (role || '').toLowerCase();
const formatRoleLabel = (role) => {
const normalized = normalizeRole(role);
if (!normalized) return 'Other';
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
};
const compareCorrespondents = (a, b) => {
const indexA = CORRESPONDENT_ROLES.indexOf(a.role);
const indexB = CORRESPONDENT_ROLES.indexOf(b.role);
const rankedA = indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA;
const rankedB = indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB;
if (rankedA !== rankedB) {
return rankedA - rankedB;
}
return (a.name || '').localeCompare(b.name || '');
};
const sortCorrespondents = (entries = []) =>
entries
.map((entry) => ({
id: entry.id,
name: entry.name || '',
role: normalizeRole(entry.role),
}))
.sort(compareCorrespondents);
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
<div className="correspondent-list">
{entries.length ? (
entries.map((entry) => (
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
<span className="correspondent-pill__label">
<strong>{formatRoleLabel(entry.role)}</strong>
<span>
{entry.name}
{showCount && entry.count ? ` (${entry.count})` : ''}
</span>
</span>
{onRemove ? (
<button
type="button"
className="correspondent-pill__remove"
onClick={() => onRemove(entry)}
aria-label={`Remove ${entry.name} as ${formatRoleLabel(entry.role)}`}
>
×
</button>
) : null}
</span>
))
) : (
<span className="meta">No correspondents yet.</span>
)}
</div>
);
const TagSection = ({
title,
tags = [],
onRemove,
onAdd,
emptyMessage = 'No tags yet.',
addPlaceholder = 'Add or create tag',
addButtonLabel = 'Add',
datalistId,
datalistOptions = [],
className,
}) => (
<div className={className}>
<dt>{title}</dt>
<div className="tag-list">
{tags.length ? (
tags.map((tag) => {
const key = tag.id ?? tag.label;
const style = getTagColorStyle(tag.color);
return (
<span key={key} className="tag-pill" style={style || undefined}>
{tag.label}{' '}
{onRemove ? (
<button type="button" onClick={() => onRemove(tag)}>
×
</button>
) : null}
</span>
);
})
) : (
<span className="meta">{emptyMessage}</span>
)}
</div>
{onAdd ? (
<form
className="inline"
onSubmit={(event) => {
event.preventDefault();
const input = event.currentTarget.elements.tag;
const value = input.value.trim();
if (!value) return;
onAdd({ value, input });
}}
>
<input name="tag" placeholder={addPlaceholder} list={datalistId} />
<button type="submit">{addButtonLabel}</button>
{datalistId ? (
<datalist id={datalistId}>
{datalistOptions.map((option) => (
<option key={option.id || option.label || option} value={option.label || option} />
))}
</datalist>
) : null}
</form>
) : null}
</div>
);
const CorrespondentSection = ({
title,
entries = [],
onRemove,
onAdd,
showCount = false,
addPlaceholder = 'Add or create correspondent',
addButtonLabel = 'Add',
datalistId,
datalistOptions = [],
className,
}) => (
<div className={className}>
<dt>{title}</dt>
<CorrespondentPills entries={entries} onRemove={onRemove} showCount={showCount} />
{onAdd ? (
<form
className="correspondent-form"
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const nameInput = form.elements.correspondent;
const roleSelect = form.elements.role;
const value = nameInput.value.trim();
const role = roleSelect.value;
if (!value) return;
onAdd({ name: value, role, input: nameInput });
form.reset();
}}
>
<input
name="correspondent"
placeholder={addPlaceholder}
list={datalistId}
/>
<select name="role" defaultValue={CORRESPONDENT_ROLES[0]}>
{CORRESPONDENT_ROLES.map((role) => (
<option key={role} value={role}>
{role.charAt(0).toUpperCase() + role.slice(1)}
</option>
))}
</select>
<button type="submit">{addButtonLabel}</button>
{datalistId ? (
<datalist id={datalistId}>
{datalistOptions.map((name) => (
<option key={name} value={name} />
))}
</datalist>
) : null}
</form>
) : null}
</div>
);
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);
const sign = index % 2 === 0 ? 1 : -1;
return magnitude * sign;
};
const PreviewStack = ({
items = [],
maxItems = MAX_PREVIEW_STACK_ITEMS,
emptyMessage = 'Preview unavailable',
onItemActivate,
onOpenPreview,
activeItemId = null,
}) => {
if (!items.length) {
return <span className="meta">{emptyMessage}</span>;
}
const limited = items.slice(0, maxItems);
const hasMultiple = limited.length > 1;
const preparedItems = useMemo(
() =>
limited.map((entry, index) => ({
entry,
angle: index === 0 ? 0 : computeStackAngle(entry.id, index),
})),
[limited],
);
return (
<div className="preview-stack preview-stack--stacked">
{preparedItems.map(({ entry, angle }, index) => {
const transform = hasMultiple
? `translate(-50%, -50%) rotate(${angle}deg)`
: 'translate(-50%, -50%)';
const isFront = index === 0;
return (
<div
key={entry.id || index}
className={`preview-stack__item orientation-${entry.orientation || 'landscape'}`}
style={{
zIndex: preparedItems.length - index,
transform,
}}
aria-hidden={hasMultiple && !onItemActivate && !onOpenPreview ? 'true' : undefined}
>
<img
src={entry.url}
alt={entry.alt || ''}
className="preview-stack__image"
onClick={(event) => {
event.stopPropagation();
if (isFront && onOpenPreview) {
onOpenPreview(entry.id);
} else if (onItemActivate) {
onItemActivate(entry.id);
}
}}
onKeyDown={(event) => {
if (!onItemActivate && !onOpenPreview) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
if (isFront && onOpenPreview) {
onOpenPreview(entry.id);
} else {
onItemActivate?.(entry.id);
}
}
}}
/>
</div>
);
})}
</div>
);
};
const DetailPanel = ({
selectedDocuments = [],
tags = [],
tagLookupById = new Map(),
onTagAdd,
onTagRemove,
onRegenerateThumbnails,
previewEntry,
onOpenPreview,
onBulkTagAdd,
onBulkTagRemove,
onBulkReanalyze,
onBulkCorrespondentAdd,
onBulkCorrespondentRemove,
onPromoteSelection,
activePreviewId = null,
onUpdateTitle = async () => false,
ensureAssetUrl = null,
getDocumentAsset = () => null,
correspondents = [],
onCorrespondentAdd,
onCorrespondentRemove,
resolveApiPath,
}) => {
const selectedCount = selectedDocuments.length;
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
const [titleEditDocId, setTitleEditDocId] = useState(null);
const [titleDraft, setTitleDraft] = useState('');
const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState(null);
const [ocrOpen, setOcrOpen] = useState(false);
const [ocrUrl, setOcrUrl] = useState(null);
const [ocrLoading, setOcrLoading] = useState(false);
const [ocrError, setOcrError] = useState(null);
useEffect(() => {
if (!singleDoc) {
setTitleEditDocId(null);
setTitleDraft('');
setTitleError(null);
setTitleSaving(false);
setOcrOpen(false);
setOcrLoading(false);
setOcrError(null);
setOcrUrl(null);
return;
}
if (titleEditDocId && titleEditDocId !== singleDoc.id) {
setTitleEditDocId(null);
setTitleDraft('');
setTitleError(null);
setTitleSaving(false);
}
}, [singleDoc, titleEditDocId]);
useEffect(() => {
setOcrOpen(false);
setOcrLoading(false);
setOcrError(null);
setOcrUrl(null);
}, [singleDoc?.id]);
const startTitleEdit = useCallback(() => {
if (!singleDoc) return;
setTitleEditDocId(singleDoc.id);
setTitleDraft(singleDoc.title || singleDoc.original_name || '');
setTitleError(null);
}, [singleDoc]);
const cancelTitleEdit = useCallback(() => {
setTitleEditDocId(null);
setTitleDraft('');
setTitleError(null);
setTitleSaving(false);
}, []);
const submitTitleEdit = useCallback(
async (event) => {
event.preventDefault();
if (!singleDoc) return;
const trimmed = titleDraft.trim();
if (!trimmed) {
setTitleError('Title cannot be empty.');
return;
}
setTitleSaving(true);
try {
const ok = await onUpdateTitle(singleDoc.id, trimmed);
if (ok) {
setTitleEditDocId(null);
setTitleDraft('');
setTitleError(null);
} else {
setTitleError('Failed to update title.');
}
} finally {
setTitleSaving(false);
}
},
[singleDoc, titleDraft, onUpdateTitle],
);
const handlePreviewActivate = useCallback(
(docId) => {
if (!docId) return;
onPromoteSelection?.(docId);
},
[onPromoteSelection],
);
const hasOcrAsset = useMemo(
() => Boolean(singleDoc && getDocumentAsset(singleDoc, 'ocr-text')),
[singleDoc, getDocumentAsset],
);
const loadOcrUrl = useCallback(async () => {
if (!singleDoc) {
return;
}
const asset = getDocumentAsset(singleDoc, 'ocr-text');
if (!asset) {
setOcrError('No OCR text available for this document.');
setOcrUrl(null);
return;
}
setOcrLoading(true);
setOcrError(null);
try {
let entry = asset;
let url = entry?.url ||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
if (!url && typeof ensureAssetUrl === 'function') {
const ensured = await ensureAssetUrl(singleDoc.id, asset, { force: false });
if (ensured) {
entry = ensured;
}
url = entry?.url ||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
}
if (!url) {
throw new Error('OCR text URL is unavailable.');
}
setOcrUrl(url);
} catch (error) {
setOcrError(error.message || 'Failed to load OCR text.');
setOcrUrl(null);
} finally {
setOcrLoading(false);
}
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
const openOcrModal = useCallback(() => {
if (!singleDoc) {
return;
}
setOcrOpen(true);
loadOcrUrl();
}, [singleDoc, loadOcrUrl]);
const closeOcrModal = useCallback(() => {
setOcrOpen(false);
}, []);
const makePreviewItem = useCallback(
(doc) => {
if (!doc) return null;
const url = resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
if (!url) {
return null;
}
const asset = getDocumentAsset(doc, 'preview');
const width = Number(asset?.metadata?.width) || 0;
const height = Number(asset?.metadata?.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',
};
},
[ensureAssetUrl, getDocumentAsset],
);
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);
return item ? [item] : [];
}, [singleDoc, makePreviewItem]);
const stackPreviews = useMemo(
() =>
stackDocuments
.map((doc) => makePreviewItem(doc))
.filter(Boolean),
[stackDocuments, makePreviewItem],
);
useEffect(() => {
if (!ensureAssetUrl) {
return;
}
stackDocuments.forEach((doc) => {
resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
});
}, [stackDocuments, ensureAssetUrl, getDocumentAsset]);
const bulkTagUnion = useMemo(() => {
if (!selectedDocuments.length) return [];
const tagMap = new Map();
selectedDocuments.forEach((doc) => {
(doc.tags || []).forEach((tag) => {
const label = (tag?.label || '').trim();
if (!label) return;
if (!tagMap.has(label)) {
const fallback = tagLookupById.get(tag.id) || {};
tagMap.set(label, {
id: tag.id,
label,
color: tag.color || fallback.color,
});
}
});
});
return [...tagMap.values()].sort((a, b) => a.label.localeCompare(b.label));
}, [selectedDocuments, tagLookupById]);
const stackTotalSizeBytes = useMemo(() => {
if (!stackPreviews.length) return 0;
const byId = new Map(selectedDocuments.map((doc) => [doc.id, doc]));
return stackPreviews.reduce((sum, item) => {
const source = byId.get(item.id);
const bytes = source?.current_version?.size_bytes;
return sum + (typeof bytes === 'number' ? bytes : 0);
}, 0);
}, [stackPreviews, selectedDocuments]);
const availableCorrespondents = useMemo(
() => (Array.isArray(correspondents) ? correspondents : []),
[correspondents],
);
const correspondentOptions = useMemo(() => {
const seen = new Set();
return availableCorrespondents
.map((entry) => (entry?.name || '').trim())
.filter((name) => {
if (!name) return false;
const lower = name.toLowerCase();
if (seen.has(lower)) {
return false;
}
seen.add(lower);
return true;
});
}, [availableCorrespondents]);
const singleCorrespondents = useMemo(() => {
if (!singleDoc) return [];
return sortCorrespondents(singleDoc.correspondents || []);
}, [singleDoc]);
const bulkCorrespondents = useMemo(() => {
if (selectedDocuments.length <= 1) {
const doc = selectedDocuments[0];
return doc ? sortCorrespondents(doc.correspondents || []) : [];
}
const map = new Map();
selectedDocuments.forEach((doc) => {
if (!doc?.id) return;
(doc.correspondents || []).forEach((entry) => {
if (!entry?.id) return;
const normalizedRole = normalizeRole(entry.role);
const key = `${entry.id}:${normalizedRole}`;
if (!map.has(key)) {
map.set(key, {
id: entry.id,
name: entry.name || '',
role: normalizedRole,
documentIds: new Set(),
});
}
map.get(key).documentIds.add(doc.id);
});
});
return [...map.values()]
.map((entry) => ({
id: entry.id,
name: entry.name,
role: entry.role,
documentIds: [...entry.documentIds],
count: entry.documentIds.size,
}))
.sort(compareCorrespondents);
}, [selectedDocuments]);
const handleBulkCorrespondentRemove = useCallback(
(entry) => {
if (!entry?.id) return;
const normalizedRole = normalizeRole(entry.role);
if (onBulkCorrespondentRemove) {
return onBulkCorrespondentRemove({
assignments: [
{
correspondent_id: entry.id,
role: normalizedRole,
},
],
documentIds: entry.documentIds,
});
}
if (!onCorrespondentRemove) return;
const targets = entry.documentIds && entry.documentIds.length
? entry.documentIds
: selectedDocuments
.filter((doc) =>
(doc.correspondents || []).some(
(item) => item.id === entry.id && normalizeRole(item.role) === normalizedRole,
),
)
.map((doc) => doc.id);
return Promise.all(
targets.map((documentId) =>
onCorrespondentRemove({
documentId,
correspondentId: entry.id,
role: normalizedRole,
}),
),
).catch(() => {});
},
[bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
);
const renderSingle = () => {
if (!singleDoc) {
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
}
const displayName = singleDoc.title || singleDoc.original_name;
const downloadHref = singleDoc.current_version?.download_path
? resolveApiPath?.(singleDoc.current_version.download_path)
: null;
const isEditingTitle = titleEditDocId === singleDoc.id;
const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
const issuedAt = singleDoc.issued_at
? new Date(singleDoc.issued_at).toLocaleString()
: '—';
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
const metadata =
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
return (
<>
<div>
<div className="preview-pane preview-pane--stack">
<PreviewStack
items={singlePreviewItems}
maxItems={1}
emptyMessage="Preview loading…"
onItemActivate={handlePreviewActivate}
onOpenPreview={onOpenPreview}
activeItemId={activePreviewId}
/>
</div>
</div>
<div className="doc-title-row">
{isEditingTitle ? (
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
<input
value={titleDraft}
onChange={(event) => {
setTitleDraft(event.target.value);
if (titleError) {
setTitleError(null);
}
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelTitleEdit();
}
}}
aria-label="Document title"
autoFocus
/>
<button type="submit" disabled={titleSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelTitleEdit}
disabled={titleSaving}
>
Cancel
</button>
</form>
) : (
<>
<h3 style={{ margin: 0 }}>{displayName}</h3>
<button
type="button"
className="icon-button ghost"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
</>
)}
</div>
{titleError ? <div className="status-inline error">{titleError}</div> : null}
<div className="meta">
<div>
<strong>Uploaded:</strong>{' '}
{singleDoc.uploaded_at ? new Date(singleDoc.uploaded_at).toLocaleString() : '—'}
</div>
<div>
<strong>Size:</strong>{' '}
{sizeLabel}
</div>
<div>
<strong>Type:</strong> {singleDoc.content_type || 'Unknown'}
</div>
<div>
<strong>Issued:</strong> {issuedAt}
</div>
<div>
<strong>Original filename:</strong>{' '}
{singleDoc.original_name}
</div>
</div>
<div className="detail-actions">
<a
className="button-link with-icon"
href={downloadHref || '#'}
target="_blank"
rel="noopener noreferrer"
aria-disabled={!downloadHref}
onClick={(event) => {
if (!downloadHref) {
event.preventDefault();
}
}}
>
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
<button
type="button"
className="secondary"
onClick={() => onOpenPreview(singleDoc.id)}
>
Open preview
</button>
<button
type="button"
className="secondary"
onClick={() => onRegenerateThumbnails(singleDoc.id)}
>
Re-run analysis
</button>
</div>
{hasOcrAsset ? (
<div className="detail-ocr-trigger">
<button type="button" className="secondary" onClick={openOcrModal}>
View OCR text
</button>
</div>
) : null}
<TagSection
title="Tags"
tags={tagsForDoc.map((tag) => ({
id: tag.id,
label: tag.label,
color: tag.color || tagLookupById.get(tag.id)?.color,
}))}
onRemove={(tag) => onTagRemove(singleDoc.id, tag.id)}
onAdd={({ value, input }) => onTagAdd(singleDoc, value, input)}
datalistId="tag-catalog-single"
datalistOptions={tags}
/>
<CorrespondentSection
title="Correspondents"
entries={singleCorrespondents}
onRemove={(entry) =>
onCorrespondentRemove?.({
documentId: singleDoc.id,
correspondentId: entry.id,
role: entry.role,
})
}
onAdd={({ name, role, input }) =>
onCorrespondentAdd?.({
document: singleDoc,
name,
role,
input,
})
}
datalistId="correspondent-catalog-single"
datalistOptions={correspondentOptions}
/>
{metadata && (
<div>
<dt>Metadata</dt>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</div>
)}
{hasOcrAsset && ocrOpen
? createPortal(
<div className="modal-backdrop" role="presentation" onClick={closeOcrModal}>
<div
className="modal modal--ocr"
role="dialog"
aria-modal="true"
aria-label="OCR text"
onClick={(event) => event.stopPropagation()}
>
<div className="modal__header">
<h3>OCR Text</h3>
<button
type="button"
className="icon-button ghost"
onClick={closeOcrModal}
aria-label="Close OCR text"
>
×
</button>
</div>
<div className="modal__body detail-ocr__content">
{ocrLoading ? (
<div className="meta">Loading OCR text</div>
) : ocrError ? (
<div className="status-inline error">{ocrError}</div>
) : ocrUrl ? (
<iframe
title="OCR text"
src={ocrUrl}
className="detail-ocr__frame"
/>
) : (
<div className="meta">OCR text empty.</div>
)}
</div>
</div>
</div>,
document.body,
)
: null}
</>
);
};
const renderBulk = () => {
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
return (
<>
<div>
<div className="preview-pane preview-pane--stack">
<PreviewStack
items={stackPreviews}
emptyMessage="No previews available."
onItemActivate={handlePreviewActivate}
onOpenPreview={onOpenPreview}
activeItemId={activePreviewId}
/>
</div>
</div>
<h3 style={{ margin: 0 }}>{countLabel}</h3>
<div className="meta">
<div>
<strong>Total size (stack):</strong> {sizeLabel}
</div>
</div>
<TagSection
title="Tags"
tags={bulkTagUnion}
emptyMessage="No tags assigned."
onRemove={(tag) => onBulkTagRemove?.({ label: tag.label })}
onAdd={({ value, input }) => onBulkTagAdd?.({ label: value, input })}
addPlaceholder="Add tag to selection"
addButtonLabel="Add tag"
datalistId="tag-catalog-bulk"
datalistOptions={tags}
className="bulk-tags"
/>
<CorrespondentSection
title="Correspondents"
entries={bulkCorrespondents}
onRemove={handleBulkCorrespondentRemove}
onAdd={({ name, role, input }) =>
onBulkCorrespondentAdd?.({ name, role, input })
}
addPlaceholder="Add correspondent to selection"
datalistId="correspondent-catalog-bulk"
datalistOptions={correspondentOptions}
showCount
className="bulk-correspondents"
/>
<button
type="button"
className="secondary"
onClick={() => onBulkReanalyze?.()}
>
Re-analyze selection
</button>
</>
);
};
return (
<aside className="detail-panel column">
<div className="column-body scrollable">
{selectedCount <= 1 ? renderSingle() : renderBulk()}
</div>
</aside>
);
};
export default DetailPanel;
+786
View File
@@ -0,0 +1,786 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors';
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon } from '../ui/icons';
const FOLDER_ICON_SRC = '/folder.svg';
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
const url = useMemo(
() =>
resolveDocumentAssetUrl(document, 'thumbnail', {
ensureAssetUrl,
getAsset: getDocumentAsset,
}),
[document, ensureAssetUrl, getDocumentAsset],
);
return (
<div className="document-thumbnail-wrapper">
{url ? (
<img
src={url}
alt={alt || ''}
className="document-thumbnail"
draggable={false}
onDragStart={(event) => event.preventDefault()}
/>
) : (
<div className="thumb-placeholder">DOC</div>
)}
</div>
);
};
const DocumentsTable = ({
currentFolderName,
breadcrumbs,
onRefresh,
onShowSkeuoWorkspace = () => {},
onRequestCreateFolder,
creatingFolder = false,
subfolders,
documents,
searchResults,
isFilterActive,
onFolderSelect,
onFolderDrop,
onFolderDragOver,
onFolderDragLeave,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
onFolderDelete,
selectedFolderIds = [],
onFolderRowClick,
onDocumentRowClick,
onDocumentOpen,
selectedDocumentIds,
focusedDocumentId,
focusedRowKey,
draggingDocumentIds = [],
onDocumentDragStart,
onDocumentDragEnd,
onDocumentDelete,
onFolderRename,
onDocumentRename,
tagLookupById,
onDocumentListFocus,
onDocumentListKeyDown,
onFocusedRowChange,
ensureAssetUrl = null,
getDocumentAsset = () => null,
getDownloadHref,
onTagClick,
isSearchLoading = false,
onDocumentTagDrop,
viewMode = 'list',
onViewModeChange,
onClearSelection,
}) => {
const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents;
const selectedSet = useMemo(
() => new Set(selectedDocumentIds),
[selectedDocumentIds],
);
const selectedFolderSet = useMemo(
() => new Set(selectedFolderIds || []),
[selectedFolderIds],
);
const draggingSet = useMemo(
() => new Set(draggingDocumentIds || []),
[draggingDocumentIds],
);
const scrollRef = useRef(null);
const isGridView = viewMode === 'grid';
const handleSetViewMode = useCallback(
(nextMode) => {
if (!onViewModeChange) {
return;
}
onViewModeChange(nextMode);
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
},
[onViewModeChange],
);
const isTagDragEvent = useCallback((event) => {
const types = Array.from(event.dataTransfer?.types || []);
return TAG_MIME_TYPES.some((type) => types.includes(type));
}, []);
const ensureFocusedRowVisible = useCallback(() => {
if (!focusedRowKey) return;
const container = scrollRef.current;
if (!container) return;
let selector = null;
if (focusedRowKey.startsWith('document:')) {
selector = `#document-row-${focusedRowKey.slice('document:'.length)}`;
} else if (focusedRowKey.startsWith('folder:')) {
selector = `#folder-row-${focusedRowKey.slice('folder:'.length)}`;
}
if (!selector) {
return;
}
const row = container.querySelector(selector);
if (!row || !container.contains(row)) {
return;
}
const header = container.querySelector('thead');
const headerHeight = header ? header.getBoundingClientRect().height : 0;
const rowTop = row.offsetTop;
const rowBottom = rowTop + row.offsetHeight;
const visibleTop = container.scrollTop + headerHeight;
const visibleBottom = container.scrollTop + container.clientHeight;
if (rowTop < visibleTop) {
container.scrollTop = Math.max(rowTop - headerHeight, 0);
return;
}
if (rowBottom > visibleBottom) {
const nextScrollTop = rowBottom - container.clientHeight;
container.scrollTop = Math.max(nextScrollTop, 0);
}
}, [focusedRowKey]);
useEffect(() => {
ensureFocusedRowVisible();
}, [ensureFocusedRowVisible]);
const activeDescendantId = useMemo(() => {
if (!focusedRowKey) return undefined;
if (focusedRowKey.startsWith('document:')) {
return `document-row-${focusedRowKey.slice('document:'.length)}`;
}
if (focusedRowKey.startsWith('folder:')) {
return `folder-row-${focusedRowKey.slice('folder:'.length)}`;
}
return undefined;
}, [focusedRowKey]);
const handleDocumentTagDragOver = useCallback(
(event) => {
if (!isTagDragEvent(event)) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
event.currentTarget.classList.add('tag-drop-target');
},
[isTagDragEvent],
);
const handleDocumentTagDragLeave = useCallback(
(event) => {
if (!isTagDragEvent(event)) {
return;
}
if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) {
return;
}
event.currentTarget.classList.remove('tag-drop-target');
},
[isTagDragEvent],
);
const handleDocumentTagDrop = useCallback(
(event, documentId) => {
if (!isTagDragEvent(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
event.currentTarget.classList.remove('tag-drop-target');
const payload =
event.dataTransfer.getData('application/x-papercrate-tag') ||
event.dataTransfer.getData('text/papercrate-tag');
if (!payload) {
return;
}
try {
const parsed = JSON.parse(payload);
if (parsed?.id && onDocumentTagDrop) {
onDocumentTagDrop(documentId, parsed);
}
} catch (
// eslint-disable-next-line no-empty
error
) {}
},
[isTagDragEvent, onDocumentTagDrop],
);
const handleGridBackgroundClick = useCallback(
(event) => {
if (event.target !== event.currentTarget) {
return;
}
onClearSelection?.();
},
[onClearSelection],
);
return (
<section
className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`}
>
<div className="column-header">
<div className="column-header__titles">
<nav className="breadcrumb" aria-label="Folder breadcrumbs">
{breadcrumbs.map((crumb, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<span key={crumb.id} className="breadcrumb-item">
{isLast ? (
<span className="breadcrumb-current">{crumb.name}</span>
) : (
<a
href="#"
onClick={(event) => {
event.preventDefault();
onFolderSelect(crumb.id);
}}
>
{crumb.name}
</a>
)}
{!isLast && <span className="breadcrumb-separator"></span>}
</span>
);
})}
</nav>
{showingSearchResults && (
<div className="column-subtitle">Search results</div>
)}
</div>
<div className="header-actions">
<div className="view-toggle" role="group" aria-label="Change view">
<button
type="button"
className={`view-toggle__button${isGridView ? '' : ' active'}`}
onClick={() => handleSetViewMode('list')}
aria-pressed={!isGridView}
title="List view"
>
<ViewListIcon className="view-toggle__icon" size={18} />
</button>
<button
type="button"
className={`view-toggle__button${isGridView ? ' active' : ''}`}
onClick={() => handleSetViewMode('grid')}
aria-pressed={isGridView}
title="Icons view"
>
<ViewGridIcon className="view-toggle__icon" size={18} />
</button>
</div>
<button
type="button"
onClick={onRequestCreateFolder}
disabled={creatingFolder}
>
{creatingFolder ? 'Creating…' : 'New folder'}
</button>
<button className="secondary" onClick={onRefresh}>
Refresh
</button>
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
Desk View
</button>
</div>
</div>
<div className="column-body">
<div
ref={scrollRef}
className="documents-scroll"
tabIndex={0}
onFocus={(event) => {
if (event.target === scrollRef.current) {
onDocumentListFocus?.();
}
}}
onKeyDown={(event) => {
if (event.target !== scrollRef.current) {
return;
}
if (onDocumentListKeyDown) {
onDocumentListKeyDown(event);
}
}}
aria-activedescendant={isGridView ? undefined : activeDescendantId}
>
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
<div className="empty-state">
Drop files anywhere or onto a folder to upload documents.
</div>
) : isGridView ? (
<div
className="documents-grid"
role="list"
onClick={handleGridBackgroundClick}
>
{!showingSearchResults &&
subfolders.map((folder) => {
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderSet.has(folder.id);
const classes = ['document-card', 'folder-card'];
if (isDraggingFolder) classes.push('is-dragging');
if (isSelectedFolder) classes.push('selected');
return (
<div
key={folder.id}
className={classes.join(' ')}
role="listitem"
id={`folder-card-${folder.id}`}
draggable={canDragFolder}
onClick={(event) => {
onFolderRowClick?.(folder.id, event);
const shouldNavigate =
!event.defaultPrevented &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey;
if (shouldNavigate) {
onFolderSelect(folder.id);
}
}}
onDoubleClick={(event) => {
event.preventDefault();
onFolderSelect(folder.id);
}}
onDragOver={(event) => onFolderDragOver(event, folder.id)}
onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop(event, folder.id)}
onDragStart={(event) => {
if (canDragFolder) {
onFolderDragStart(event, folder.id);
}
}}
onDragEnd={(event) => {
if (canDragFolder) {
onFolderDragEnd(event);
}
}}
>
<div className="folder-card__icon">
<img
src={FOLDER_ICON_SRC}
alt="Folder"
className="folder-card__icon-svg"
/>
</div>
<div className="folder-card__meta">
<div className="folder-card__name" title={folder.name}>
{folder.name}
</div>
</div>
</div>
);
})}
{rows.map((doc) => {
const isSelected = selectedSet.has(doc.id);
const isDraggingDoc = draggingSet.has(doc.id);
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
const visibleTags = tagList.slice(0, 3);
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
const cardClasses = ['document-card', 'document'];
if (isSelected) cardClasses.push('selected');
if (isDraggingDoc) cardClasses.push('is-dragging');
return (
<div
key={doc.id}
className={cardClasses.join(' ')}
role="listitem"
id={`document-card-${doc.id}`}
onClick={(event) => onDocumentRowClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)}
draggable
onDragStart={(event) => onDocumentDragStart(event, doc)}
onDragEnd={onDocumentDragEnd}
onDragOver={(event) => handleDocumentTagDragOver(event)}
onDragOverCapture={(event) => handleDocumentTagDragOver(event)}
onDragLeave={handleDocumentTagDragLeave}
onDragLeaveCapture={handleDocumentTagDragLeave}
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
onDropCapture={(event) => handleDocumentTagDrop(event, doc.id)}
>
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title || doc.original_name}`}
/>
<div className="document-card__meta">
<div
className="document-card__title"
title={doc.title || doc.original_name}
>
{doc.title || doc.original_name}
</div>
{visibleTags.length > 0 && (
<div className="document-card__tags">
{visibleTags.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}
onClick={(event) => {
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}}
role="button"
tabIndex={0}
draggable
onDragStart={(event) => {
event.stopPropagation();
try {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copyMove';
}
const payload = JSON.stringify({
id: tag.id,
label: tag.label,
sourceDocId: doc.id,
});
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
event.dataTransfer?.setData('text/papercrate-tag', payload);
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
} catch (
// eslint-disable-next-line no-empty
error
) {}
}}
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}
}}
>
{tag.label}
</span>
);
})}
{remainingTagCount > 0 && (
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
) : (
<table aria-multiselectable="true">
<thead>
<tr>
<th className="thumb-column">Preview</th>
<th>Name</th>
<th>Type</th>
<th>Updated</th>
<th className="actions-column">Actions</th>
</tr>
</thead>
<tbody>
{!showingSearchResults &&
subfolders.map((folder) => {
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderSet.has(folder.id);
return (
<tr
key={folder.id}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
}${isSelectedFolder ? ' selected' : ''}`}
id={`folder-row-${folder.id}`}
onClick={(event) => {
onFolderRowClick?.(folder.id, event);
const shouldNavigate =
!event.defaultPrevented &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey;
if (shouldNavigate) {
onFolderSelect(folder.id);
}
if (scrollRef.current) {
scrollRef.current.focus({ preventScroll: true });
}
onFocusedRowChange?.(`folder:${folder.id}`);
}}
onDoubleClick={(event) => {
event.preventDefault();
onFolderSelect(folder.id);
}}
onDragOver={(event) => onFolderDragOver(event, folder.id)}
onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop(event, folder.id)}
draggable={canDragFolder}
onDragStart={(event) => {
if (canDragFolder) {
onFolderDragStart(event, folder.id);
}
}}
onDragEnd={(event) => {
if (canDragFolder) {
onFolderDragEnd(event);
}
}}
>
<td className="thumb-cell">
<div className="thumb-icon">
<img
src={FOLDER_ICON_SRC}
alt="Folder"
className="thumb-icon__image"
/>
</div>
</td>
<td className="doc-list__name">
<div className="doc-list__name-content">
<span>{folder.name}</span>
{folder.id !== 'root' && (
<button
type="button"
className="icon-button ghost doc-list__icon-button"
onClick={(event) => {
event.stopPropagation();
if (!onFolderRename) return;
const nextName = window.prompt('Rename folder', folder.name || '');
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === folder.name) {
return;
}
onFolderRename(folder.id, trimmed);
}}
title="Rename folder"
aria-label={`Rename folder ${folder.name}`}
>
<EditIcon className="doc-list__icon" size={16} />
</button>
)}
</div>
</td>
<td>Folder</td>
<td></td>
<td className="actions">
<button
type="button"
className="icon-button danger"
onClick={(event) => {
event.stopPropagation();
onFolderDelete(folder.id);
}}
>
Delete
</button>
</td>
</tr>
);
})}
{rows.map((doc) => {
const isSelected = selectedSet.has(doc.id);
const isDraggingDoc = draggingSet.has(doc.id);
const rowClasses = ['document'];
if (isSelected) rowClasses.push('selected');
if (isDraggingDoc) rowClasses.push('is-dragging');
const downloadHref = getDownloadHref?.(doc) || null;
return (
<tr
key={doc.id}
className={rowClasses.join(' ')}
id={`document-row-${doc.id}`}
onClick={(event) => onDocumentRowClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)}
draggable
onDragStart={(event) => onDocumentDragStart(event, doc)}
onDragEnd={onDocumentDragEnd}
onDragOver={handleDocumentTagDragOver}
onDragLeave={handleDocumentTagDragLeave}
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
>
<td className="thumb-cell">
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title || doc.original_name}`}
/>
</td>
<td className="doc-list__name">
<div className="doc-name">
<div className="doc-list__name-content">
<span className="doc-name__title">{doc.title || doc.original_name}</span>
<button
type="button"
className="icon-button ghost doc-list__icon-button"
onClick={(event) => {
event.stopPropagation();
if (!onDocumentRename) return;
const nextName = window.prompt(
'Rename document',
doc.title || doc.original_name || '',
);
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
return;
}
onDocumentRename(doc.id, trimmed);
}}
title="Rename document"
aria-label={`Rename document ${doc.title || doc.original_name}`}
>
<EditIcon className="doc-list__icon" size={16} />
</button>
</div>
{(doc.tags || []).length > 0 && (
<div className="doc-name__tags">
{(doc.tags || []).map((tag) => {
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
return (
<span
key={tag.id}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
onClick={(event) => {
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}}
role="button"
tabIndex={0}
draggable
onDragStart={(event) => {
event.stopPropagation();
try {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copyMove';
}
const payload = JSON.stringify({
id: tag.id,
label: tag.label,
sourceDocId: doc.id,
});
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
event.dataTransfer?.setData('text/papercrate-tag', payload);
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
} catch (
// eslint-disable-next-line no-empty
error
) {}
}}
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}
}}
>
{tag.label}
</span>
);
})}
</div>
)}
</div>
</td>
<td>{doc.content_type || 'Document'}</td>
<td>
{doc.updated_at
? new Date(doc.updated_at).toLocaleString()
: '—'}
</td>
<td className="actions">
<div className="action-buttons">
{downloadHref ? (
<a
className="button-link with-icon"
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
onAuxClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.stopPropagation()}
>
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
) : (
<span className="meta">No download</span>
)}
<button
type="button"
className="danger"
onClick={(event) => {
event.stopPropagation();
onDocumentDelete?.(doc.id);
}}
>
Delete
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{rows.length === 0 && showingSearchResults && !isGridView && !isSearchLoading && (
<div className="empty-state">No documents match the current filters.</div>
)}
{showingSearchResults && rows.length > 0 && (
<div className="search-hint">
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
</div>
)}
</div>
{isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading && (
<div className="empty-state empty-state--global">
No documents match the current filters.
</div>
)}
</section>
);
};
export default DocumentsTable;
export { DocumentThumbnailImage };
+33
View File
@@ -0,0 +1,33 @@
import { useCallback } from 'react';
const noop = () => {};
const normalizeMessage = (error) => {
if (!error) return 'Something went wrong.';
if (typeof error === 'string') {
return error;
}
const { response, message } = error;
if (response?.data?.error) return response.data.error;
if (response?.data?.message) return response.data.message;
return message || 'Something went wrong.';
};
const useApiError = ({
logger = console,
onReport = noop,
} = {}) => {
return useCallback(
(error, { message, variant = 'error', retry = null } = {}) => {
const normalizedMessage = message || normalizeMessage(error);
if (logger && typeof logger.error === 'function') {
logger.error('[API]', normalizedMessage, error);
}
onReport({ message: normalizedMessage, variant, retry, error });
return normalizedMessage;
},
[logger, onReport],
);
};
export default useApiError;
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Papercrate</title>
</head>
<body>
<script src="/config.js"></script>
<main id="app"></main>
</body>
</html>
File diff suppressed because it is too large Load Diff
+303
View File
@@ -0,0 +1,303 @@
import React, { useCallback, useMemo } from 'react';
import { ChevronIcon, TrashIcon, EditIcon } from '../ui/icons';
const FOLDER_ICON_SRC = '/folder.svg';
import { getTagColorStyle } from '../utils/colors';
const FolderNode = ({
node,
depth,
isSelected,
onToggle,
onSelect,
onDrop,
onDragOver,
onDragLeave,
onDelete,
onRename,
renderChildren,
onFolderDragStart,
onFolderDragEnd,
draggingFolderId,
}) => {
const isRoot = node.id === 'root';
const hasChildren = node.children.length > 0;
const canToggle = !isRoot && (hasChildren || !node.loaded);
const showChevron = !isRoot && hasChildren;
const icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null;
const canDrag = !isRoot;
const isDragging = draggingFolderId === node.id;
const isExpanded = isRoot ? true : Boolean(node.expanded);
const rowClasses = ['folder-row'];
if (isSelected) {
rowClasses.push('active');
}
const handleToggleClick = (event) => {
event.stopPropagation();
if (canToggle) {
onToggle(node.id);
}
};
return (
<li className={`folder-node${isDragging ? ' is-dragging' : ''}`}>
<div
className={rowClasses.join(' ')}
draggable={canDrag}
onClick={() => onSelect(node.id)}
onDragOver={(event) => onDragOver(event, node.id)}
onDragLeave={onDragLeave}
onDrop={(event) => onDrop(event, node.id)}
onDragStart={(event) => {
if (!canDrag || !onFolderDragStart) return;
onFolderDragStart(event, node.id);
}}
onDragEnd={(event) => {
if (onFolderDragEnd) {
onFolderDragEnd(event);
}
}}
>
{!isRoot && (
<span
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
onClick={handleToggleClick}
>
{icon}
</span>
)}
<span className="name">
<img src={FOLDER_ICON_SRC} alt="Folder" className="folder-icon-image" />
{node.name}
</span>
{node.id !== 'root' && (
<div className="folder-row__actions">
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
if (!onRename) return;
const nextName = window.prompt('Rename folder', node.name || '');
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === node.name) {
return;
}
onRename(node.id, trimmed);
}}
title="Rename folder"
aria-label={`Rename folder ${node.name}`}
>
<EditIcon className="icon-edit" size={18} />
</button>
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onDelete(node.id);
}}
title="Delete folder"
aria-label={`Delete folder ${node.name}`}
>
<TrashIcon className="icon-trash" size={18} />
</button>
</div>
)}
</div>
{isExpanded && node.children.length > 0 && (
<ul className={`folder-children${depth === 0 ? ' folder-children--level1' : ''}`}>
{renderChildren(node.children, depth + 1)}
</ul>
)}
</li>
);
};
const Sidebar = ({
folderNodes,
onToggle,
onSelect,
onDrop,
onDragOver,
onDragLeave,
onDeleteFolder,
onRenameFolder,
selectedFolder,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
tags = [],
activeTagIds = [],
onToggleTagFilter,
correspondents = [],
activeCorrespondentIds = [],
onToggleCorrespondentFilter,
}) => {
const sortedCorrespondents = useMemo(
() =>
[...correspondents].sort((a, b) =>
(a?.name || '').localeCompare(b?.name || '', undefined, { sensitivity: 'base' }),
),
[correspondents],
);
const activeCorrespondentSet = useMemo(
() => new Set(activeCorrespondentIds || []),
[activeCorrespondentIds],
);
const handleToggleTag = onToggleTagFilter || (() => {});
const activeTagSet = new Set(activeTagIds);
const renderNodes = useCallback(
(ids, depth) =>
ids.map((id) => {
const node = folderNodes.get(id);
if (!node) return null;
return (
<FolderNode
key={id}
node={node}
depth={depth}
isSelected={selectedFolder === id}
onToggle={() => onToggle(id)}
onSelect={onSelect}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDelete={onDeleteFolder}
onRename={onRenameFolder}
renderChildren={renderNodes}
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
draggingFolderId={draggedFolderId}
/>
);
}),
[
folderNodes,
selectedFolder,
onToggle,
onSelect,
onDrop,
onDragOver,
onDragLeave,
onDeleteFolder,
onRenameFolder,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
],
);
const rootNode = folderNodes.get('root');
return (
<aside className="sidebar column">
<div className="sidebar-section sidebar-section--folders">
<div className="sidebar-section__header">
<h3>Folders</h3>
</div>
<ul className="folder-tree">
{rootNode && renderNodes([rootNode.id], 0)}
</ul>
</div>
<div className="sidebar-section">
<div className="sidebar-section__header">
<h3>Tags</h3>
<span className="meta">{tags.length}</span>
</div>
<div
className={`sidebar-tag-cloud${
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
}`}
role="list"
>
{tags.length ? (
tags.map((tag) => {
const isActive = activeTagSet.has(tag.id);
const style = getTagColorStyle(tag.color);
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
return (
<button
key={tag.id}
type="button"
role="listitem"
className={className}
style={style || undefined}
onClick={() => handleToggleTag(tag.id)}
aria-pressed={isActive}
draggable
onDragStart={(event) => {
try {
const payload = JSON.stringify({
id: tag.id,
label: tag.label,
color: tag.color || null,
});
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('application/x-papercrate-tag', payload);
event.dataTransfer.setData('text/papercrate-tag', payload);
} catch (
// eslint-disable-next-line no-empty
error
) {}
}}
>
{tag.label}
</button>
);
})
) : (
<span className="meta">No tags yet</span>
)}
</div>
</div>
<div className="sidebar-section">
<div className="sidebar-section__header">
<h3>Correspondents</h3>
<span className="meta">{correspondents.length}</span>
</div>
<ul className="sidebar-correspondent-list">
{sortedCorrespondents.length ? (
sortedCorrespondents.map((correspondent) => {
const isActive = activeCorrespondentSet.has(correspondent.id);
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
const label = correspondent.name || 'Unnamed';
const handleSelect = () => {
const nextId = isActive ? null : correspondent.id;
onToggleCorrespondentFilter?.(nextId);
};
return (
<li key={correspondent.id}>
<span
className={className}
role="button"
tabIndex={0}
onClick={handleSelect}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleSelect();
}
}}
>
{label}
</span>
</li>
);
})
) : (
<li className="meta">No correspondents yet</li>
)}
</ul>
</div>
</aside>
);
};
export default Sidebar;
export { FolderNode };
+317
View File
@@ -0,0 +1,317 @@
/* Skeuomorphic workspace styles */
.skeuo-main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.skeuo-shell {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
background-color: var(--surface-subtle);
}
.skeuo-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem 0.5rem;
background-color: var(--surface-subtle);
}
.skeuo-header__meta {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.skeuo-header__meta h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.skeuo-breadcrumbs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--muted);
}
.skeuo-crumb {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.skeuo-crumb.is-current {
color: var(--fg);
font-weight: 600;
}
.skeuo-crumb-separator {
opacity: 0.4;
}
.skeuo-header__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.skeuo-canvas {
flex: 1;
position: relative;
overflow: hidden;
margin: 0;
}
.skeuo-empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
color: var(--muted);
font-size: 0.95rem;
}
.skeuo-item {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
width: auto;
cursor: grab;
touch-action: none;
transform-origin: center center;
transition: transform 0.28s ease, box-shadow 0.16s ease;
outline: none;
will-change: transform;
}
.skeuo-item__body {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
transition: width 0.28s ease, height 0.28s ease;
}
.skeuo-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 4px;
}
.skeuo-item.is-dragging {
cursor: grabbing;
transition: none;
}
.skeuo-item.is-zoomed {
cursor: pointer;
}
.skeuo-item.is-tag-target .skeuo-item__card {
outline: 1em dashed var(--accent);
outline-offset: 1.41em;
}
.skeuo-item.is-tag-pending .skeuo-item__card {
opacity: 0.6;
}
.skeuo-item.is-filtered-out {
opacity: 0.12;
pointer-events: none;
filter: blur(15px) grayscale(100%);
transition: opacity 0.6s ease, filter 0.28s ease;
z-index: 0 !important;
}
.skeuo-item__tags {
--tag-scale: 1;
position: absolute;
top: 0;
right: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
align-items: flex-end;
transform-origin: top right;
transform: scale(var(--tag-scale)) translate(-0.5em, 0.5em);
transition: transform 0.28s ease;
}
.skeuo-tag {
display: inline-flex;
align-items: center;
justify-content: flex-start;
padding: 0.28rem 0.7rem;
border-radius: 1px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
color: var(--fg);
font-size: 1rem;
font-weight: 600;
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.18);
text-align: left;
white-space: nowrap;
user-select: none;
}
.skeuo-item__tags .skeuo-tag {
font-size: 0.75rem;
padding: 0.18rem 0.55rem;
pointer-events: auto;
cursor: grab;
transition: transform 0.16s ease, opacity 0.2s ease;
}
.skeuo-tag.is-drag-hidden {
opacity: 0.4;
pointer-events: none;
}
.skeuo-tag span {
display: block;
pointer-events: none;
user-select: none;
}
.skeuo-item__tags .skeuo-tag.is-tear-pending {
opacity: 0.4;
}
.skeuo-tag-shelf {
position: absolute;
top: 2.25rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
align-items: flex-end;
max-height: calc(100% - 4rem);
overflow: auto;
padding: 0;
z-index: 100000;
}
.skeuo-tag-shelf .skeuo-tag {
cursor: grab;
}
.skeuo-tag-shelf .skeuo-tag:active {
cursor: grabbing;
}
.skeuo-tag-shelf .skeuo-tag.is-inactive {
opacity: 0.25;
}
.skeuo-tag-shelf .skeuo-tag.is-active {
opacity: 1;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
}
.skeuo-tag-add {
width: 2.4rem;
height: 2.4rem;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
background: rgba(255, 255, 255, 0.8);
color: var(--fg);
font-size: 1.4rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-top: 0.4rem;
transition: background 0.2s ease, transform 0.2s ease;
}
.skeuo-tag-add:hover {
background: rgba(255, 255, 255, 1);
transform: translateY(-1px);
}
.skeuo-tag-add:active {
transform: translateY(0);
}
.skeuo-tag-add:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
body.skeuo-cursor-remove,
body.skeuo-cursor-remove * {
cursor: not-allowed !important;
}
.skeuo-item__shadow {
display: none;
}
.skeuo-item__card {
position: relative;
border-radius: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24);
overflow: hidden;
}
.skeuo-item__card--empty {
background:
radial-gradient(circle at 28% 24%, rgba(255, 255, 255, 0.32), transparent 60%),
radial-gradient(circle at 72% 78%, rgba(0, 0, 0, 0.08), transparent 65%),
linear-gradient(135deg, #e6e1d6 0%, #d2cdc2 100%);
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.skeuo-item__placeholder {
font-size: 0.95rem;
font-weight: 500;
letter-spacing: normal;
color: var(--muted);
}
.skeuo-item__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
gap: 0.5rem;
padding: 1rem;
text-align: center;
}
.skeuo-item__title {
font-size: 0.95rem;
font-weight: 500;
color: #3b3b3b;
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
import { generateRandomTagColor } from './utils/colors';
class TagManager {
constructor({ colorGenerator = generateRandomTagColor } = {}) {
this.colorGenerator = typeof colorGenerator === 'function' ? colorGenerator : generateRandomTagColor;
}
normalizeLabel(label) {
if (typeof label !== 'string') {
return '';
}
return label.trim();
}
buildPayload({ label, color } = {}) {
const normalizedLabel = this.normalizeLabel(label);
if (!normalizedLabel) {
throw new Error('Tag label is required.');
}
const payload = { label: normalizedLabel };
const trimmedColor = typeof color === 'string' && color.trim().length ? color.trim() : null;
payload.color = trimmedColor || this.colorGenerator();
return payload;
}
}
export default TagManager;
+255
View File
@@ -0,0 +1,255 @@
import React, { useCallback, useMemo, useState } from 'react';
import { getTagColorStyle, HEX_COLOR_PATTERN } from '../utils/colors';
function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) {
const [editingId, setEditingId] = useState(null);
const [draftLabel, setDraftLabel] = useState('');
const [draftColor, setDraftColor] = useState('');
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState(null);
const startEdit = useCallback((tag) => {
setEditingId(tag.id);
setDraftLabel(tag.label || '');
setDraftColor(tag.color || '');
}, []);
const cancelEdit = useCallback(() => {
setEditingId(null);
setDraftLabel('');
setDraftColor('');
setSaving(false);
}, []);
const colorPickerValue = useMemo(() => {
if (!draftColor) {
return '#3366ff';
}
const match = HEX_COLOR_PATTERN.exec(draftColor.trim());
if (!match) {
return '#3366ff';
}
return `#${match[1].toLowerCase()}`;
}, [draftColor]);
const handleSave = useCallback(async () => {
if (!editingId) return;
const trimmedLabel = draftLabel.trim();
if (!trimmedLabel) {
onNotify?.('Tag label cannot be empty.', 'error');
return;
}
const trimmedColor = draftColor.trim();
const colorPattern = /^#([0-9a-fA-F]{6})$/;
if (trimmedColor && !colorPattern.test(trimmedColor)) {
onNotify?.('Colors must use the #RRGGBB format.', 'error');
return;
}
setSaving(true);
try {
await onUpdateTag(editingId, {
label: trimmedLabel,
color: trimmedColor ? trimmedColor : null,
});
cancelEdit();
} catch (updateError) {
const message = updateError?.message || 'Failed to update tag.';
onNotify?.(message, 'error');
} finally {
setSaving(false);
}
}, [editingId, draftLabel, draftColor, onUpdateTag, cancelEdit, onNotify]);
const handleKeyDown = useCallback(
(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleSave();
} else if (event.key === 'Escape') {
event.preventDefault();
cancelEdit();
}
},
[handleSave, cancelEdit],
);
const handleDelete = useCallback(
async (tag) => {
if (!tag?.id || typeof onDeleteTag !== 'function') {
return;
}
setDeletingId(tag.id);
try {
await onDeleteTag(tag.id);
if (editingId === tag.id) {
cancelEdit();
}
} catch (deleteError) {
const message = deleteError?.message || 'Failed to delete tag.';
onNotify?.(message, 'error');
} finally {
setDeletingId(null);
}
},
[onDeleteTag, editingId, cancelEdit, onNotify],
);
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 || Boolean(deletingId)}
>
Refresh
</button>
</div>
</div>
<div className="column-body tags-panel__body">
{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 || deletingId === tag.id}
autoFocus
/>
) : (
<span
className="badge tag-chip"
style={getTagColorStyle(tag.color) || undefined}
>
{tag.label}
</span>
)}
</td>
<td>
{isEditing ? (
<div className="tags-table__color-editor">
<input
type="color"
className="tags-table__color-picker"
value={colorPickerValue}
onChange={(event) => setDraftColor(event.target.value)}
disabled={saving || deletingId === tag.id}
aria-label="Pick tag color"
/>
{draftColor && (
<button
type="button"
className="secondary"
onClick={() => setDraftColor('')}
disabled={saving || deletingId === tag.id}
>
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 || deletingId === tag.id}
>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelEdit}
disabled={saving || deletingId === tag.id}
>
Cancel
</button>
<button
type="button"
className="danger"
onClick={() => handleDelete(tag)}
disabled={deletingId === tag.id}
>
Delete
</button>
</div>
) : (
<div className="tags-table__row-actions">
<button
type="button"
className="secondary"
onClick={() => startEdit(tag)}
disabled={deletingId === tag.id}
>
Edit
</button>
<button
type="button"
className="danger"
onClick={() => handleDelete(tag)}
disabled={deletingId === tag.id}
>
Delete
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</section>
);
}
export default TagsPanel;
+106
View File
@@ -0,0 +1,106 @@
import {
IconChevronRight as TablerChevronRight,
IconDownload as TablerDownload,
IconFolderFilled,
IconPencil,
IconTagFilled,
IconUserFilled,
IconTrash,
IconLayoutList,
IconLayoutGrid,
} from '@tabler/icons-react';
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
export const ChevronIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<TablerChevronRight
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const TrashIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconTrash
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const EditIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconPencil
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const FolderIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
<IconFolderFilled
className={composeClassName('icon icon--fill', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
<IconTagFilled
className={composeClassName('icon icon--fill', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const CorrespondentIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
<IconUserFilled
className={composeClassName('icon icon--fill', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<TablerDownload
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ViewListIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconLayoutList
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ViewGridIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconLayoutGrid
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export default {
ChevronIcon,
TrashIcon,
EditIcon,
FolderIcon,
TagIcon,
CorrespondentIcon,
DownloadIcon,
ViewListIcon,
ViewGridIcon,
};
+134
View File
@@ -0,0 +1,134 @@
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
const clamp01 = (value) => Math.min(1, Math.max(0, value));
const gammaEncode = (channel) =>
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
const oklchToHex = (l, c, h) => {
const hr = (h * Math.PI) / 180;
const a = Math.cos(hr) * c;
const b = Math.sin(hr) * c;
const l1 = l + 0.3963377774 * a + 0.2158037573 * b;
const m1 = l - 0.1055613458 * a - 0.0638541728 * b;
const s1 = l - 0.0894841775 * a - 1.291485548 * b;
const l3 = l1 ** 3;
const m3 = m1 ** 3;
const s3 = s1 ** 3;
const r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
const g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
const bLin = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3;
if ([r, g, bLin].some((channel) => channel < 0 || channel > 1)) {
return null;
}
const sr = Math.round(clamp01(gammaEncode(r)) * 255);
const sg = Math.round(clamp01(gammaEncode(g)) * 255);
const sb = Math.round(clamp01(gammaEncode(bLin)) * 255);
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
};
const hslToHex = (h, s, l) => {
const normalizedH = ((h % 360) + 360) % 360;
const sat = clamp01(s);
const light = clamp01(l);
const chroma = (1 - Math.abs(2 * light - 1)) * sat;
const hPrime = normalizedH / 60;
const x = chroma * (1 - Math.abs((hPrime % 2) - 1));
let r1 = 0;
let g1 = 0;
let b1 = 0;
if (hPrime >= 0 && hPrime < 1) {
r1 = chroma;
g1 = x;
} else if (hPrime >= 1 && hPrime < 2) {
r1 = x;
g1 = chroma;
} else if (hPrime >= 2 && hPrime < 3) {
g1 = chroma;
b1 = x;
} else if (hPrime >= 3 && hPrime < 4) {
g1 = x;
b1 = chroma;
} else if (hPrime >= 4 && hPrime < 5) {
r1 = x;
b1 = chroma;
} else {
r1 = chroma;
b1 = x;
}
const m = light - chroma / 2;
const r = clamp01(r1 + m);
const g = clamp01(g1 + m);
const b = clamp01(b1 + m);
const sr = Math.round(r * 255);
const sg = Math.round(g * 255);
const sb = Math.round(b * 255);
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
};
export 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()}`,
};
};
export const relativeLuminance = ({ r, g, b }) => {
const toLinear = (channel) => {
const normalized = channel / 255;
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
};
const [red, green, blue] = [toLinear(r), toLinear(g), toLinear(b)];
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
};
export const getReadableTextColor = (hex, { light = '#1f1f1f', dark = '#ffffff' } = {}) => {
const rgb = hexToRgb(hex);
if (!rgb) return dark;
const luminance = relativeLuminance(rgb);
return luminance > 0.6 ? light : dark;
};
export const getTagColorStyle = (hex) => {
const rgb = hexToRgb(hex);
if (!rgb) return null;
return {
backgroundColor: rgb.hex,
borderColor: rgb.hex,
color: getReadableTextColor(rgb.hex),
};
};
export const generateRandomTagColor = () => {
const bucketCount = 8;
const bucketWidth = 360 / bucketCount;
const bucket = Math.floor(Math.random() * bucketCount);
const baseHue = bucket * bucketWidth;
const hueJitter = bucketWidth * 0.35;
const hue = baseHue + (Math.random() * 2 - 1) * hueJitter;
const saturation = 0.45 + Math.random() * 0.2; // 0.45 - 0.65
const lightness = 0.55 + Math.random() * 0.1; // 0.55 - 0.65
return hslToHex(hue, saturation, lightness);
};
export { HEX_COLOR_PATTERN };
+18
View File
@@ -0,0 +1,18 @@
export const formatFileSize = (value) => {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B';
}
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
let index = 0;
let amount = bytes;
while (amount >= 1024 && index < units.length - 1) {
amount /= 1024;
index += 1;
}
return `${amount.toFixed(2)} ${units[index]}`;
};