This commit is contained in:
2025-10-28 01:38:19 +01:00
parent 7df9a6415a
commit 1e177580c9
6 changed files with 211 additions and 82 deletions
+9 -7
View File
@@ -16,22 +16,24 @@ Health
Documents
---------
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true unless explicitly set to `false` without filters), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_descendants` (defaults to true unless explicitly set to `false` without filters), `status` (`active`, `deleted`, or `all`; defaults to `active`), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
- GET /api/documents/check?checksum=<sha256> - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata.
- POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id` and `role`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document.
- POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document.
- POST /api/documents/bulk/move - Move multiple documents to a target folder.
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Use `action=add` (default) to attach correspondents or `action=remove` to detach the provided correspondents.
- POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents.
- GET /api/documents/:id - Retrieve metadata and current version details for a document.
- PATCH /api/documents/:id - Update document metadata (currently title).
- DELETE /api/documents/:id - Soft-delete a document.
- GET /api/documents/:id/download - Create a pre-signed download URL for the current version.
- PATCH /api/documents/:id/folder - Move a document to another folder.
- POST /api/documents/:id/restore - Restore a soft-deleted document. Optional body `{ "folder_id": <uuid> }` to send it to a specific folder; defaults to the original folder or root if missing.
- GET /api/documents/:id/versions - List version history for a document.
- GET /api/documents/:id/versions/:version_id - Fetch metadata and assets for a specific version.
- POST /api/documents/:id/tags - Assign one or more tags to a document.
- DELETE /api/documents/:id/tags/:tag_id - Remove a single tag from a document.
- POST /api/documents/:id/correspondents - Assign correspondents to roles (`assignments[]` with `correspondent_id` and `role`; optional `replace=true` overwrites existing assignments for those roles). Valid roles: `sender`, `receiver`, `other`.
- DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment (requires `role` query string).
- POST /api/documents/:id/correspondents - Assign correspondents (`assignments[]` with `correspondent_id`; optional `replace=true` overwrites existing assignments).
- DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment.
Document Assets
---------------
@@ -62,7 +64,7 @@ Tags
Correspondents
--------------
- GET /api/correspondents - List correspondents with usage totals and per-role counts (roles: `sender`, `receiver`, `other`).
- GET /api/correspondents - List correspondents with usage totals.
- POST /api/correspondents - Create a correspondent (name + optional metadata JSON).
- PATCH /api/correspondents/:id - Update name and/or metadata.
- DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document.
+19 -19
View File
@@ -1291,15 +1291,30 @@ const DetailPanel = ({
<aside className="detail-panel panel">
<div className="panel-header">
<div className="panel-actions">
<button
type="button"
className="icon-button ghost"
onClick={onClose}
<button
type="button"
className="icon-button ghost"
onClick={onClose}
aria-label="Close detail panel"
title="Close detail panel"
>
<ChevronsRightIcon />
</button>
{singleDoc ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onOpenPreview(singleDoc.id);
}}
aria-label="Open preview"
title="Open preview"
disabled={!singleHasPreview}
>
<WindowMaximizeIcon />
</button>
) : null}
<div className="spacer" />
{isBulkSelection && onBulkReanalyze ? (
<button
@@ -1328,21 +1343,6 @@ const DetailPanel = ({
<DownloadIcon />
</a>
) : null}
{singleDoc ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onOpenPreview(singleDoc.id);
}}
aria-label="Open preview"
title="Open preview"
disabled={!singleHasPreview}
>
<WindowMaximizeIcon />
</button>
) : null}
{showOcrAction ? (
<button
type="button"
+14 -1
View File
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors';
import DetailPanel from '../detail/DetailPanel';
import {
DownloadIcon,
EditIcon,
@@ -1125,6 +1126,7 @@ export const createDocumentsSurface = ({
parentBreadcrumb,
onNavigateParent,
renderSidebarToggle,
detailProps,
}) => {
const {
currentFolderName,
@@ -1174,11 +1176,22 @@ export const createDocumentsSurface = ({
)
: null;
const detail = (() => {
if (!detailProps) {
return null;
}
const count = detailProps.selectedDocuments?.length || 0;
if (!count) {
return null;
}
return <DetailPanel {...detailProps} />;
})();
return {
key: 'documents',
variant: 'documents',
header: { title, subtitle, leading, actions },
content: <DocumentsTable {...tableProps} showHeader={false} />,
supportsDetail: true,
detail,
};
};
+5 -7
View File
@@ -25,7 +25,6 @@ import {
import './styles.css';
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import useApiError from './hooks/useApiError';
import DetailPanel from './detail/DetailPanel';
import TagsPanel from './tags/TagsPanel';
import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
import TagManager from './tag_manager';
@@ -5485,12 +5484,14 @@ const DocumentsRoute = () => {
parentBreadcrumb,
onNavigateParent: parentBreadcrumb ? handleNavigateParent : null,
renderSidebarToggle,
detailProps: detailPanelProps,
});
}, [
documentsTableProps,
parentBreadcrumb,
handleNavigateParent,
renderSidebarToggle,
detailPanelProps,
]);
const previewSurface = useMemo(() => {
@@ -5550,16 +5551,13 @@ const DocumentsRoute = () => {
);
}
const supportsDetail = Boolean(surface.supportsDetail);
const detailCount = detailPanelProps.selectedDocuments?.length || 0;
const showDetailPanel = supportsDetail && detailCount > 0;
const variant = surface.variant || 'documents';
const mainContentClass = `main-content main-content--${variant}${
showDetailPanel ? ' main-content--has-detail' : ''
surface.detail ? ' main-content--has-detail' : ''
}`;
const bodyClass = `main-content__body main-content__body--${variant}${
showDetailPanel ? ' main-content__body--has-detail' : ''
surface.detail ? ' main-content__body--has-detail' : ''
}`;
const header = surface.header || null;
@@ -5586,7 +5584,7 @@ const DocumentsRoute = () => {
</div>
) : null}
<div className={bodyClass}>{surface.content}</div>
{showDetailPanel ? <DetailPanel {...detailPanelProps} /> : null}
{surface.detail || null}
</div>
</DocumentsLayout>
);
+77 -27
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager';
import { formatFileSize } from '../utils/format';
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
@@ -17,10 +17,59 @@ const PreviewWorkspace = ({
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
const metadata =
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
const folderName = document.folder_path || document.folder_name || null;
const issuedAt = document.issued_at || document.current_version?.issued_at || null;
const createdAt = document.created_at || null;
const updatedAt = document.updated_at || null;
const tags = Array.isArray(document.tags) ? document.tags : [];
const correspondents = Array.isArray(document.correspondents) ? document.correspondents : [];
const metadataSummary = useMemo(() => {
const rows = [];
if (mime) rows.push(['Type', mime]);
if (sizeLabel) rows.push(['Size', sizeLabel]);
if (issuedAt) rows.push(['Issued', new Date(issuedAt).toLocaleString()]);
if (createdAt) rows.push(['Created', new Date(createdAt).toLocaleString()]);
if (updatedAt) rows.push(['Updated', new Date(updatedAt).toLocaleString()]);
if (folderName) rows.push(['Folder', folderName]);
if (tags.length) {
rows.push(['Tags', tags.map((tag) => tag.label || tag.name || tag.slug).filter(Boolean).join(', ')]);
}
if (correspondents.length) {
rows.push([
'Correspondents',
correspondents
.map((entry) => entry.name || entry.label || entry.slug)
.filter(Boolean)
.join(', '),
]);
}
return rows;
}, [mime, sizeLabel, issuedAt, createdAt, updatedAt, folderName, tags, correspondents]);
return (
<section className="preview-workspace">
<div className="preview-workspace__body">
<aside className="preview-workspace__sidebar">
<div className="preview-workspace__info">
{metadataSummary.length ? (
<dl className="preview-workspace__summary">
{metadataSummary.map(([label, value]) => (
<div className="preview-workspace__summary-row" key={label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
))}
</dl>
) : null}
</div>
{metadata ? (
<section className="preview-workspace__metadata">
<h4>Metadata payload</h4>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</section>
) : null}
</aside>
<div className="preview-workspace__viewer">
{!previewEntry?.url ? (
<div className="preview-workspace__message">Loading preview</div>
) : (
@@ -31,14 +80,6 @@ const PreviewWorkspace = ({
/>
)}
</div>
{metadata ? (
<section className="preview-workspace__metadata">
<h3>Metadata</h3>
{sizeLabel ? <p><strong>Size:</strong> {sizeLabel}</p> : null}
<p><strong>Type:</strong> {mime}</p>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</section>
) : null}
</section>
);
};
@@ -52,7 +93,6 @@ export const createPreviewWorkspaceHeaderActions = ({
resolveApiPath,
notifyApiError,
onRegenerate,
onClose,
}) => {
if (!document) {
return null;
@@ -101,11 +141,11 @@ export const createPreviewWorkspaceHeaderActions = ({
</a>
) : null}
{hasOcr ? (
<button
type="button"
className="icon-button ghost"
onClick={handleOcrClick}
aria-label="View OCR text"
<button
type="button"
className="icon-button ghost"
onClick={handleOcrClick}
aria-label="View OCR text"
title="View OCR text"
>
<TextScanIcon />
@@ -120,15 +160,6 @@ export const createPreviewWorkspaceHeaderActions = ({
>
<AnalyzeIcon />
</button>
<button
type="button"
className="icon-button ghost"
onClick={() => onClose?.()}
aria-label="Close preview"
title="Close preview"
>
<CloseIcon />
</button>
</>
);
};
@@ -150,7 +181,27 @@ export const createPreviewSurface = ({
const title = document.title || document.original_name || 'Document preview';
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const leading = sidebarToggle ? <>{sidebarToggle}</> : null;
const closeButton = onClose
? (
<button
type="button"
className="icon-button ghost"
onClick={() => onClose?.()}
aria-label="Close preview"
title="Close preview"
>
<CloseIcon />
</button>
)
: null;
const leading = sidebarToggle || closeButton
? (
<>
{sidebarToggle}
{closeButton}
</>
)
: null;
const header = {
title,
subtitle: null,
@@ -162,7 +213,6 @@ export const createPreviewSurface = ({
resolveApiPath,
notifyApiError,
onRegenerate,
onClose,
}),
};
+87 -21
View File
@@ -11,7 +11,7 @@
--fg: oklch(0.32 0.005 calc(270deg + var(--warmth)));
--muted: oklch(0.54 0.008 calc(270deg + var(--warmth)));
--sidebar-fg: oklch(0.56 0.007 calc(270deg + var(--warmth)));
--border: oklch(0.88 0.002 calc(270deg + var(--warmth)));
--border: oklch(0.92 0.002 calc(270deg + var(--warmth)));
/* --- Accent (primary) --- */
--accent: oklch(0.61 0.20 calc(260deg + var(--warmth)));
@@ -445,6 +445,10 @@ button.danger:hover:not([disabled]) {
box-shadow: -12px 0 24px -12px var(--shadow-faint);
}
.documents-main:not(.documents-main--sidebar-collapsed) .main-content {
border-left: 1px solid var(--border);
}
.main-content__header {
padding: 0.5rem 1.25rem;
justify-content: space-between;
@@ -538,9 +542,9 @@ button.danger:hover:not([disabled]) {
.preview-workspace {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 1.5rem;
min-height: 0;
padding: 1rem 1.5rem;
}
.preview-workspace__header {
@@ -672,17 +676,84 @@ button.danger:hover:not([disabled]) {
gap: 0.5rem;
}
.preview-workspace__body {
flex: 1;
.preview-workspace__sidebar {
flex: 0 0 20em;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 1rem;
max-width: 100%;
overflow: auto;
}
.preview-workspace__info {
background: var(--surface-subtle);
padding: 1rem;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
}
.preview-workspace__summary {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.preview-workspace__summary-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.preview-workspace__summary-row dt {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.preview-workspace__summary-row dd {
margin: 0;
font-size: 0.9rem;
font-weight: 500;
}
.preview-workspace__metadata {
background: var(--surface-subtle);
padding: 1rem;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
font-size: 0.85rem;
overflow: auto;
max-height: 40vh;
}
.preview-workspace__metadata h4 {
margin: 0 0 0.5rem;
font-size: 0.9rem;
}
.preview-workspace__metadata pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.preview-workspace__viewer {
flex: 1;
min-width: 0;
background: var(--surface-subtle);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
display: flex;
position: relative;
overflow: hidden;
}
.preview-workspace__object {
width: 100%;
height: 100%;
border: none;
background: #fff;
}
.tags-panel__body {
@@ -865,20 +936,14 @@ button.danger:hover:not([disabled]) {
}
.preview-workspace__message {
color: var(--muted);
font-size: 0.95rem;
}
.preview-workspace__metadata {
border-radius: 2px;
padding: 0.75rem;
background: var(--surface);
max-height: 180px;
overflow: auto;
}
.preview-workspace__metadata h3 {
margin: 0 0 0.5rem;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
background: rgba(0, 0, 0, 0.65);
padding: 0.5rem 1rem;
border-radius: 999px;
font-size: 0.9rem;
}
@@ -1948,6 +2013,7 @@ button.danger:hover:not([disabled]) {
height: 100%;
background: var(--surface);
box-shadow: 0 0 24px var(--shadow-soft);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 20;