split
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 19s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 46s

This commit is contained in:
2025-10-14 15:49:33 +02:00
parent d7f221850f
commit 8a9f3644a1
5 changed files with 737 additions and 686 deletions
+424
View File
@@ -0,0 +1,424 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors';
import { DownloadIcon, FolderIcon } 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(
() =>
resolveDocumentAssetUrl(document, 'thumbnail', {
ensureAssetUrl,
getAsset: getDocumentAsset,
}),
[document, ensureAssetUrl, getDocumentAsset],
);
if (url) {
return (
<img
src={url}
alt={alt || ''}
className="document-thumbnail"
/>
);
}
return <div className="thumb-placeholder">DOC</div>;
};
const DocumentsTable = ({
currentFolderName,
breadcrumbs,
onRefresh,
onShowSkeuoWorkspace = () => {},
onRequestCreateFolder,
creatingFolder = false,
subfolders,
documents,
searchResults,
isFilterActive,
onFolderSelect,
onFolderDrop,
onFolderDragOver,
onFolderDragLeave,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
onFolderDelete,
onDocumentRowClick,
onDocumentOpen,
selectedDocumentIds,
focusedDocumentId,
focusedRowKey,
draggingDocumentIds = [],
onDocumentDragStart,
onDocumentDragEnd,
onDocumentDelete,
filterBar,
tagLookupById,
onDocumentListFocus,
onDocumentListKeyDown,
onFocusedRowChange,
ensureAssetUrl = null,
getDocumentAsset = () => null,
getDownloadHref,
}) => {
const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents;
const selectedSet = useMemo(
() => new Set(selectedDocumentIds),
[selectedDocumentIds],
);
const draggingSet = useMemo(
() => new Set(draggingDocumentIds || []),
[draggingDocumentIds],
);
const scrollRef = useRef(null);
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]);
return (
<section className="documents-panel column">
<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">
<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 className="column-toolbar">{filterBar}</div>
<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={activeDescendantId}
>
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
<div className="empty-state">
Drop files anywhere or onto a folder to upload documents.
</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;
return (
<tr
key={folder.id}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
}`}
id={`folder-row-${folder.id}`}
onClick={() => 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-placeholder icon">
<FolderIcon className="icon-inline" size={18} />
</div>
</td>
<td>{folder.name}</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}
>
<td className="thumb-cell">
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title || doc.original_name}`}
/>
</td>
<td>
<div className="doc-name">
<span className="doc-name__title">{doc.title || doc.original_name}</span>
{(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}
>
{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 && isFilterActive && (
<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>
</section>
);
};
export default DocumentsTable;
export { FilterBar, DocumentThumbnailImage };
+11 -686
View File
@@ -16,7 +16,6 @@ import {
Routes,
Outlet,
useLocation,
useMatch,
useNavigate,
matchPath,
} from 'react-router-dom';
@@ -24,14 +23,10 @@ import './styles.css';
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager';
import useApiError from './hooks/useApiError';
import SkeuomorphicWorkspace from './skeuomorphic_ws';
import {
IconChevronRight,
IconDownload as TablerDownload,
IconFolderFilled,
IconPencil,
IconTagFilled,
IconTrash,
} from '@tabler/icons-react';
import { DownloadIcon, EditIcon } from './ui/icons';
import { getTagColorStyle, HEX_COLOR_PATTERN } from './utils/colors';
import Sidebar from './sidebar/Sidebar';
import DocumentsTable, { FilterBar } from './documents/DocumentsTable';
const runtimeApiBase =
typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL
@@ -145,44 +140,6 @@ const resolveApiPath = (path = '') => (API_ROOT ? `${API_ROOT}${path}` : path);
const hasFiles = (event) =>
Array.from(event.dataTransfer?.types || []).includes('Files');
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
const hexToRgb = (input) => {
if (!input) return null;
const match = HEX_COLOR_PATTERN.exec(input.trim());
if (!match) return null;
const value = parseInt(match[1], 16);
return {
r: (value >> 16) & 0xff,
g: (value >> 8) & 0xff,
b: value & 0xff,
hex: `#${match[1].toLowerCase()}`,
};
};
const relativeLuminance = ({ r, g, b }) => {
const transform = (channel) => {
const normalized = channel / 255;
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
};
const [red, green, blue] = [transform(r), transform(g), transform(b)];
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
};
const getTagColorStyle = (hex) => {
const rgb = hexToRgb(hex);
if (!rgb) return null;
const luminance = relativeLuminance(rgb);
const textColor = luminance > 0.6 ? '#1f1f1f' : '#ffffff';
return {
backgroundColor: rgb.hex,
borderColor: rgb.hex,
color: textColor,
};
};
const createRootNode = () => ({
id: 'root',
name: DEFAULT_FOLDER_NAME,
@@ -235,544 +192,7 @@ const LoginView = ({ onSubmit, status }) => (
</div>
);
const FolderNode = ({
node,
depth,
isSelected,
onToggle,
onSelect,
onDrop,
onDragOver,
onDragLeave,
onDelete,
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 ? (
<IconChevronRight className="icon toggle-icon" size="1em" stroke={1.6} />
) : 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">
<IconFolderFilled
className="icon icon--fill folder-icon"
size="1em"
stroke={0}
/>
{node.name}
</span>
{node.id !== 'root' && (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onDelete(node.id);
}}
title="Delete folder"
aria-label={`Delete folder ${node.name}`}
>
<IconTrash className="icon icon-trash" size="1em" stroke={1.6} />
</button>
)}
</div>
{isExpanded && node.children.length > 0 && (
<ul className={`folder-children${depth === 0 ? ' folder-children--level1' : ''}`}>
{renderChildren(node.children, depth + 1)}
</ul>
)}
</li>
);
};
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 rgba(0, 0, 0, 0.18)' : undefined,
}
: undefined;
return (
<button
key={tag.id}
type="button"
className={`tag-filter${isActive ? ' active' : ''}`}
onClick={() => onToggleTag(tag.id)}
style={buttonStyle}
>
{tag.label}
</button>
);
})
) : (
<span className="meta">No tags yet</span>
)}
</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(
() =>
resolveDocumentAssetUrl(document, 'thumbnail', {
ensureAssetUrl,
getAsset: getDocumentAsset,
}),
[document, ensureAssetUrl, getDocumentAsset],
);
if (url) {
return (
<img
src={url}
alt={alt || ''}
className="document-thumbnail"
/>
);
}
return <div className="thumb-placeholder">DOC</div>;
};
const DocumentsTable = ({
currentFolderName,
breadcrumbs,
onRefresh,
onShowSkeuoWorkspace = () => {},
onRequestCreateFolder = () => {},
creatingFolder = false,
subfolders,
documents,
searchResults,
isFilterActive,
onFolderSelect,
onFolderDrop,
onFolderDragOver,
onFolderDragLeave,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
onFolderDelete,
onDocumentRowClick,
onDocumentOpen,
selectedDocumentIds,
focusedDocumentId,
focusedRowKey,
draggingDocumentIds = [],
onDocumentDragStart,
onDocumentDragEnd,
onDocumentDelete,
filterBar,
tagLookupById,
onDocumentListFocus,
onDocumentListKeyDown,
onFocusedRowChange,
ensureAssetUrl = null,
getDocumentAsset = () => null,
}) => {
const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents;
const selectedSet = useMemo(
() => new Set(selectedDocumentIds),
[selectedDocumentIds],
);
const draggingSet = useMemo(
() => new Set(draggingDocumentIds || []),
[draggingDocumentIds],
);
const scrollRef = useRef(null);
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]);
return (
<section className="documents-panel column">
<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">
<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 className="column-toolbar">{filterBar}</div>
<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={activeDescendantId}
>
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
<div className="empty-state">
Drop files anywhere or onto a folder to upload documents.
</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;
return (
<tr
key={folder.id}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
}`}
id={`folder-row-${folder.id}`}
onClick={() => {
scrollRef.current?.focus({ preventScroll: true });
onFocusedRowChange?.(`folder:${folder.id}`);
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-placeholder icon">
<IconFolderFilled
className="icon icon--fill icon-inline"
size="1.1em"
stroke={0}
/>
</div>
</td>
<td>{folder.name}</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 rowClasses = ['document'];
if (isSelected) rowClasses.push('selected');
if (
focusedDocumentId === doc.id || focusedRowKey === `document:${doc.id}`
) {
rowClasses.push('focused');
}
if (draggingSet.has(doc.id)) {
rowClasses.push('dragging');
}
return (
<tr
key={doc.id}
className={rowClasses.join(' ')}
id={`document-row-${doc.id}`}
aria-selected={isSelected}
onClick={(event) => {
scrollRef.current?.focus({ preventScroll: true });
onFocusedRowChange?.(`document:${doc.id}`);
onDocumentRowClick(doc.id, event);
}}
onDoubleClick={(event) => {
event.stopPropagation();
if (onDocumentOpen) {
onDocumentOpen(doc.id);
}
}}
draggable
onDragStart={(event) => onDocumentDragStart(event, doc.id)}
onDragEnd={onDocumentDragEnd}
>
<td className="thumb-cell">
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title || doc.original_name}`}
/>
</td>
<td>
<div className="doc-name">
<span className="doc-name__title">{doc.title || doc.original_name}</span>
{(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}
>
{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">
{doc.current_version?.download_path ? (
<a
className="button-link with-icon"
href={resolveApiPath(doc.current_version.download_path)}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
onAuxClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.stopPropagation()}
>
<TablerDownload
className="icon icon-inline"
size="1em"
stroke={1.6}
/>
<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 && isFilterActive && (
<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>
</section>
);
};
const computeStackAngle = (docId, index) => {
if (index === 0) return 0;
@@ -1122,7 +542,7 @@ const DetailPanel = ({
aria-label="Edit title"
title="Edit title"
>
<IconPencil className="icon icon-inline" size="1em" stroke={1.6} />
<EditIcon className="icon-inline" />
</button>
</>
)}
@@ -1166,7 +586,7 @@ const DetailPanel = ({
}
}}
>
<TablerDownload className="icon icon-inline" size="1em" stroke={1.6} />
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
<button
@@ -1407,7 +827,7 @@ const PreviewWorkspace = ({
}
}}
>
<TablerDownload className="icon icon-inline" size="1em" stroke={1.6} />
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
<button
@@ -1440,105 +860,6 @@ const PreviewWorkspace = ({
);
};
const Sidebar = ({
folderNodes,
onToggle,
onSelect,
onDrop,
onDragOver,
onDragLeave,
onDeleteFolder,
selectedFolder,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
onShowTags,
tags = [],
}) => {
const handleShowTags = onShowTags || (() => {});
const tagsRouteMatch = useMatch('/tags');
const isTagsRoute = Boolean(tagsRouteMatch);
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}
renderChildren={renderNodes}
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
draggingFolderId={draggedFolderId}
/>
);
}),
[
folderNodes,
selectedFolder,
onToggle,
onSelect,
onDrop,
onDragOver,
onDragLeave,
onDeleteFolder,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
],
);
const rootNode = folderNodes.get('root');
return (
<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>
<span
role="button"
tabIndex={0}
className={`sidebar-item${isTagsRoute ? ' active' : ''}`}
onClick={handleShowTags}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleShowTags();
}
}}
>
<IconTagFilled
className="icon icon--fill sidebar-item__icon"
size="1em"
stroke={0}
/>
<span>All tags</span>
</span>
</div>
</aside>
);
};
const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
const [editingId, setEditingId] = useState(null);
const [draftLabel, setDraftLabel] = useState('');
@@ -5087,6 +4408,10 @@ const AppLayout = () => {
onFocusedRowChange: setFocusedRowKey,
ensureAssetUrl,
getDocumentAsset,
getDownloadHref: (doc) =>
doc?.current_version?.download_path
? resolveApiPath(doc.current_version.download_path)
: null,
};
const detailPanelProps = {
+189
View File
@@ -0,0 +1,189 @@
import React, { useCallback } from 'react';
import { useMatch } from 'react-router-dom';
import { ChevronIcon, FolderIcon, TagIcon, TrashIcon } from '../ui/icons';
const FolderNode = ({
node,
depth,
isSelected,
onToggle,
onSelect,
onDrop,
onDragOver,
onDragLeave,
onDelete,
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);
}
}}
>
<span
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
onClick={handleToggleClick}
>
{icon}
</span>
<span className="name">
<FolderIcon className="folder-icon" />
{node.name}
</span>
{node.id !== 'root' && (
<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>
{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,
selectedFolder,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
onShowTags,
tags = [],
}) => {
const handleShowTags = onShowTags || (() => {});
const tagsRouteMatch = useMatch('/tags');
const isTagsRoute = Boolean(tagsRouteMatch);
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}
renderChildren={renderNodes}
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
draggingFolderId={draggedFolderId}
/>
);
}),
[
folderNodes,
selectedFolder,
onToggle,
onSelect,
onDrop,
onDragOver,
onDragLeave,
onDeleteFolder,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
],
);
const rootNode = folderNodes.get('root');
return (
<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>
<span
role="button"
tabIndex={0}
className={`sidebar-item${isTagsRoute ? ' active' : ''}`}
onClick={handleShowTags}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleShowTags();
}
}}
>
<TagIcon className="sidebar-item__icon" />
<span>All tags</span>
</span>
</div>
</aside>
);
};
export default Sidebar;
export { FolderNode };
+73
View File
@@ -0,0 +1,73 @@
import {
IconChevronRight as TablerChevronRight,
IconDownload as TablerDownload,
IconFolderFilled,
IconPencil,
IconTagFilled,
IconTrash,
} 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 DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<TablerDownload
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export default {
ChevronIcon,
TrashIcon,
EditIcon,
FolderIcon,
TagIcon,
DownloadIcon,
};
+40
View File
@@ -0,0 +1,40 @@
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
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()}`,
};
};
const relativeLuminance = ({ r, g, b }) => {
const transform = (channel) => {
const normalized = channel / 255;
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
};
const [red, green, blue] = [transform(r), transform(g), transform(b)];
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
};
export const getTagColorStyle = (hex) => {
const rgb = hexToRgb(hex);
if (!rgb) return null;
const luminance = relativeLuminance(rgb);
const textColor = luminance > 0.6 ? '#1f1f1f' : '#ffffff';
return {
backgroundColor: rgb.hex,
borderColor: rgb.hex,
color: textColor,
};
};
export { HEX_COLOR_PATTERN };