diff --git a/docs/api.txt b/docs/api.txt index 538eb45..a391d93 100644 --- a/docs/api.txt +++ b/docs/api.txt @@ -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= - 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 `<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. diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index d6770c0..3b18863 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -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" diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx index c1fef76..8d2a58b 100644 --- a/frontend/src/documents/DocumentsTable.jsx +++ b/frontend/src/documents/DocumentsTable.jsx @@ -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, }; }; diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 8ba8bdd..6c3bfb3 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -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> ); diff --git a/frontend/src/preview/PreviewWorkspace.jsx b/frontend/src/preview/PreviewWorkspace.jsx index dc9727a..ec5ace9 100644 --- a/frontend/src/preview/PreviewWorkspace.jsx +++ b/frontend/src/preview/PreviewWorkspace.jsx @@ -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, }), }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 8ea7d63..93bfaaf 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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;