stuff
This commit is contained in:
+9
-7
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user