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 };