a lot of stuff
This commit is contained in:
+329
-200
@@ -7,6 +7,178 @@ 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;
|
||||
@@ -95,7 +267,6 @@ const DetailPanel = ({
|
||||
selectedDocuments = [],
|
||||
tags = [],
|
||||
tagLookupById = new Map(),
|
||||
tagLookupByLabel = new Map(),
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
onRegenerateThumbnails,
|
||||
@@ -103,10 +274,9 @@ const DetailPanel = ({
|
||||
onOpenPreview,
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
onBulkMove,
|
||||
onBulkReanalyze,
|
||||
folderOptions = [],
|
||||
defaultMoveTarget = 'root',
|
||||
onBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove,
|
||||
onPromoteSelection,
|
||||
activePreviewId = null,
|
||||
onUpdateTitle = async () => false,
|
||||
@@ -257,20 +427,25 @@ const DetailPanel = ({
|
||||
});
|
||||
}, [stackDocuments, ensureAssetUrl, getDocumentAsset]);
|
||||
|
||||
const commonTags = useMemo(() => {
|
||||
if (selectedCount < 2) return [];
|
||||
const tagSets = selectedDocuments.map((doc) => new Set((doc.tags || []).map((tag) => tag.label)));
|
||||
if (!tagSets.length) return [];
|
||||
const intersection = new Set(tagSets[0]);
|
||||
tagSets.slice(1).forEach((set) => {
|
||||
[...intersection].forEach((label) => {
|
||||
if (!set.has(label)) {
|
||||
intersection.delete(label);
|
||||
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 [...intersection];
|
||||
}, [selectedDocuments, selectedCount]);
|
||||
return [...tagMap.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [selectedDocuments, tagLookupById]);
|
||||
|
||||
const stackTotalSizeBytes = useMemo(() => {
|
||||
if (!stackPreviews.length) return 0;
|
||||
@@ -302,6 +477,87 @@ const DetailPanel = ({
|
||||
});
|
||||
}, [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>;
|
||||
@@ -320,21 +576,6 @@ const DetailPanel = ({
|
||||
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
|
||||
const metadata =
|
||||
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
|
||||
const docCorrespondents = Array.isArray(singleDoc.correspondents)
|
||||
? singleDoc.correspondents
|
||||
: [];
|
||||
const sortedCorrespondents = docCorrespondents.slice().sort((a, b) => {
|
||||
const roleA = (a.role || '').toLowerCase();
|
||||
const roleB = (b.role || '').toLowerCase();
|
||||
const indexA = CORRESPONDENT_ROLES.indexOf(roleA);
|
||||
const indexB = CORRESPONDENT_ROLES.indexOf(roleB);
|
||||
if (indexA !== indexB) {
|
||||
return (indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA) -
|
||||
(indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB);
|
||||
}
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
@@ -448,119 +689,39 @@ const DetailPanel = ({
|
||||
Re-run analysis
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Tags</dt>
|
||||
<div className="tag-list">
|
||||
{tagsForDoc.length ? (
|
||||
tagsForDoc.map((tag) => {
|
||||
const colorSource = tag?.color || tagLookupById.get(tag.id)?.color;
|
||||
const style = getTagColorStyle(colorSource);
|
||||
return (
|
||||
<span key={tag.id} className="tag-pill" style={style || undefined}>
|
||||
{tag.label}{' '}
|
||||
<button type="button" onClick={() => onTagRemove(singleDoc.id, tag.id)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="meta">No tags yet.</span>
|
||||
)}
|
||||
</div>
|
||||
<form
|
||||
className="inline"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const input = event.currentTarget.elements.tag;
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
onTagAdd(singleDoc, value, input);
|
||||
}}
|
||||
>
|
||||
<input name="tag" placeholder="Add or create tag" list="tag-catalog" />
|
||||
<button type="submit">Add</button>
|
||||
<datalist id="tag-catalog">
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.id} value={tag.label} />
|
||||
))}
|
||||
</datalist>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Correspondents</dt>
|
||||
<div className="correspondent-list">
|
||||
{sortedCorrespondents.length ? (
|
||||
sortedCorrespondents.map((entry) => {
|
||||
const roleLabel = entry.role
|
||||
? entry.role.charAt(0).toUpperCase() + entry.role.slice(1)
|
||||
: 'Other';
|
||||
return (
|
||||
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
|
||||
<span className="correspondent-pill__label">
|
||||
<strong>{roleLabel}</strong>
|
||||
<span>{entry.name}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="correspondent-pill__remove"
|
||||
onClick={() =>
|
||||
onCorrespondentRemove?.({
|
||||
documentId: singleDoc.id,
|
||||
correspondentId: entry.id,
|
||||
role: entry.role,
|
||||
})
|
||||
}
|
||||
aria-label={`Remove ${entry.name} as ${roleLabel}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="meta">No correspondents yet.</span>
|
||||
)}
|
||||
</div>
|
||||
<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;
|
||||
onCorrespondentAdd?.({
|
||||
document: singleDoc,
|
||||
name: value,
|
||||
role,
|
||||
input: nameInput,
|
||||
});
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
name="correspondent"
|
||||
placeholder="Add or create correspondent"
|
||||
list="correspondent-catalog"
|
||||
/>
|
||||
<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">Add</button>
|
||||
<datalist id="correspondent-catalog">
|
||||
{correspondentOptions.map((name) => (
|
||||
<option key={name} value={name} />
|
||||
))}
|
||||
</datalist>
|
||||
</form>
|
||||
</div>
|
||||
<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>
|
||||
@@ -593,64 +754,32 @@ const DetailPanel = ({
|
||||
<div>
|
||||
<strong>Total size (stack):</strong> {sizeLabel}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Common tags:</strong>{' '}
|
||||
{commonTags.length ? commonTags.join(', ') : 'None'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bulk-tags">
|
||||
<strong>Bulk tag operations</strong>
|
||||
<form
|
||||
className="inline"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const input = event.currentTarget.elements.tag;
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
onBulkTagAdd?.({ label: value, input });
|
||||
}}
|
||||
>
|
||||
<input name="tag" placeholder="Add tag to selection" list="tag-catalog" />
|
||||
<button type="submit">Add tag</button>
|
||||
</form>
|
||||
<form
|
||||
className="inline"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const input = event.currentTarget.elements.tag;
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
onBulkTagRemove?.({ label: value, input });
|
||||
}}
|
||||
>
|
||||
<input name="tag" placeholder="Remove tag from selection" list="tag-catalog" />
|
||||
<button type="submit" className="secondary">
|
||||
Remove tag
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<datalist id="tag-catalog">
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.id} value={tag.label} />
|
||||
))}
|
||||
</datalist>
|
||||
<div className="bulk-move">
|
||||
<label htmlFor="detail-bulk-move" className="meta">
|
||||
Move selection to folder
|
||||
</label>
|
||||
<select
|
||||
id="detail-bulk-move"
|
||||
name="target"
|
||||
defaultValue={defaultMoveTarget || 'root'}
|
||||
onChange={(event) => onBulkMove?.({ target: event.target.value })}
|
||||
>
|
||||
{folderOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<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"
|
||||
|
||||
@@ -3,60 +3,6 @@ import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { DownloadIcon, FolderIcon, EditIcon } from '../ui/icons';
|
||||
|
||||
const FilterBar = ({
|
||||
query,
|
||||
onQueryChange,
|
||||
tags,
|
||||
activeTagIds,
|
||||
onToggleTag,
|
||||
onClear,
|
||||
hasFilters,
|
||||
}) => (
|
||||
<div className="filter-bar">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search documents"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
/>
|
||||
<div className="tag-filters">
|
||||
{tags.length ? (
|
||||
tags.map((tag) => {
|
||||
const isActive = activeTagIds.includes(tag.id);
|
||||
const style = getTagColorStyle(tag.color);
|
||||
const buttonStyle = style
|
||||
? {
|
||||
...style,
|
||||
opacity: isActive ? 1 : 0.95,
|
||||
boxShadow: isActive ? '0 0 0 1px var(--shadow-soft)' : undefined,
|
||||
}
|
||||
: undefined;
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className={`tag-filter${isActive ? ' active' : ''}`}
|
||||
onClick={() => onToggleTag(tag.id)}
|
||||
style={buttonStyle}
|
||||
>
|
||||
{tag.label}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="meta">No tags yet</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="filter-actions">
|
||||
{hasFilters && (
|
||||
<button type="button" className="secondary" onClick={onClear}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
|
||||
const url = useMemo(
|
||||
() =>
|
||||
@@ -103,6 +49,8 @@ const DocumentsTable = ({
|
||||
onFolderDragEnd,
|
||||
draggedFolderId,
|
||||
onFolderDelete,
|
||||
selectedFolderIds = [],
|
||||
onFolderRowClick,
|
||||
onDocumentRowClick,
|
||||
onDocumentOpen,
|
||||
selectedDocumentIds,
|
||||
@@ -114,7 +62,6 @@ const DocumentsTable = ({
|
||||
onDocumentDelete,
|
||||
onFolderRename,
|
||||
onDocumentRename,
|
||||
filterBar,
|
||||
tagLookupById,
|
||||
onDocumentListFocus,
|
||||
onDocumentListKeyDown,
|
||||
@@ -131,6 +78,10 @@ const DocumentsTable = ({
|
||||
() => new Set(selectedDocumentIds),
|
||||
[selectedDocumentIds],
|
||||
);
|
||||
const selectedFolderSet = useMemo(
|
||||
() => new Set(selectedFolderIds || []),
|
||||
[selectedFolderIds],
|
||||
);
|
||||
const draggingSet = useMemo(
|
||||
() => new Set(draggingDocumentIds || []),
|
||||
[draggingDocumentIds],
|
||||
@@ -235,7 +186,6 @@ const DocumentsTable = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="column-body">
|
||||
<div className="column-toolbar">{filterBar}</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="documents-scroll"
|
||||
@@ -275,20 +225,33 @@ const DocumentsTable = ({
|
||||
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={() => {
|
||||
onFolderSelect(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)}
|
||||
@@ -413,36 +376,36 @@ const DocumentsTable = ({
|
||||
{(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}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (onTagClick) {
|
||||
onTagClick(tag.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -504,4 +467,4 @@ const DocumentsTable = ({
|
||||
};
|
||||
|
||||
export default DocumentsTable;
|
||||
export { FilterBar, DocumentThumbnailImage };
|
||||
export { DocumentThumbnailImage };
|
||||
|
||||
+784
-361
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useMatch } from 'react-router-dom';
|
||||
import {
|
||||
ChevronIcon,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CorrespondentIcon,
|
||||
EditIcon,
|
||||
} from '../ui/icons';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
|
||||
const FolderNode = ({
|
||||
node,
|
||||
@@ -138,8 +139,12 @@ const Sidebar = ({
|
||||
draggedFolderId,
|
||||
onShowTags,
|
||||
tags = [],
|
||||
activeTagIds = [],
|
||||
onToggleTagFilter,
|
||||
onShowCorrespondents,
|
||||
correspondents = [],
|
||||
activeCorrespondentIds = [],
|
||||
onToggleCorrespondentFilter,
|
||||
}) => {
|
||||
const handleShowTags = onShowTags || (() => {});
|
||||
const tagsRouteMatch = useMatch('/tags');
|
||||
@@ -147,6 +152,19 @@ const Sidebar = ({
|
||||
const handleShowCorrespondents = onShowCorrespondents || (() => {});
|
||||
const correspondentsRouteMatch = useMatch('/correspondents');
|
||||
const isCorrespondentsRoute = Boolean(correspondentsRouteMatch);
|
||||
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) =>
|
||||
@@ -221,6 +239,30 @@ const Sidebar = ({
|
||||
<TagIcon className="sidebar-item__icon" />
|
||||
<span>All tags</span>
|
||||
</span>
|
||||
<div className="sidebar-tag-cloud" 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}
|
||||
>
|
||||
{tag.label}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="meta">No tags yet</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section__header">
|
||||
@@ -231,10 +273,14 @@ const Sidebar = ({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`sidebar-item${isCorrespondentsRoute ? ' active' : ''}`}
|
||||
onClick={handleShowCorrespondents}
|
||||
onClick={() => {
|
||||
onToggleCorrespondentFilter?.(null);
|
||||
handleShowCorrespondents();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onToggleCorrespondentFilter?.(null);
|
||||
handleShowCorrespondents();
|
||||
}
|
||||
}}
|
||||
@@ -242,6 +288,39 @@ const Sidebar = ({
|
||||
<CorrespondentIcon className="sidebar-item__icon" />
|
||||
<span>All correspondents</span>
|
||||
</span>
|
||||
<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>
|
||||
);
|
||||
|
||||
+151
-28
@@ -221,14 +221,16 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
|
||||
.app-bar__main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.app-bar__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-bar h1 {
|
||||
@@ -257,6 +259,52 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-bar__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 320px;
|
||||
max-width: 420px;
|
||||
min-width: 240px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app-bar__search input[type='search'] {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.app-bar__search input[type='search']:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.app-bar__search-clear {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.3rem;
|
||||
}
|
||||
|
||||
.app-bar__search-clear:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.app-bar__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
@@ -363,6 +411,34 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.document-drag-preview__folder-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.document-drag-preview__folder-thumb svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--accent-strong, var(--accent));
|
||||
}
|
||||
|
||||
.document-drag-preview__folder-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.document-drag-preview__count {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
@@ -852,6 +928,80 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.sidebar-tag-cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0 0.1rem;
|
||||
}
|
||||
|
||||
.sidebar-tag-pill {
|
||||
border: 1px solid var(--sidebar-pill-border, rgba(15, 23, 42, 0.08));
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
background: var(--surface-soft);
|
||||
color: var(--fg);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.sidebar-tag-pill:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16);
|
||||
border-color: var(--accent-soft, rgba(59, 130, 246, 0.35));
|
||||
}
|
||||
|
||||
.sidebar-tag-pill:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sidebar-tag-pill.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-correspondent-list {
|
||||
list-style: none;
|
||||
margin: 0.4rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.sidebar-correspondent-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
text-align: left;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-correspondent-item:hover {
|
||||
background: var(--sidebar-hover-bg);
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-correspondent-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sidebar-correspondent-item.active {
|
||||
background: var(--accent-soft, rgba(59, 130, 246, 0.18));
|
||||
box-shadow: 0 0 0 1px var(--accent-soft, rgba(59, 130, 246, 0.24));
|
||||
}
|
||||
|
||||
|
||||
.documents-panel {
|
||||
padding: 1.25rem 1.25rem 0 1.25rem;
|
||||
@@ -893,7 +1043,6 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
}
|
||||
|
||||
.documents-panel .documents-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -1064,32 +1213,6 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.tag-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.32rem;
|
||||
}
|
||||
|
||||
.tag-filter {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--fg);
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease;
|
||||
}
|
||||
|
||||
.tag-filter:hover {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
|
||||
.tag-filter.active {
|
||||
box-shadow: 0 0 0 1px currentColor inset;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.32rem;
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user