From 4f6e1fd91371571b55148b0dfd5e271c6a920a9e Mon Sep 17 00:00:00 2001 From: Jim Lancaster <40281233+zoltan57@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:54:24 -0500 Subject: [PATCH] UI style refresh with Gemini's help --- .../job/~acceptance-criteria.md.saved.bak | 154 ++++ src/transcription/ui/components/cards.py | 15 + .../ui/components/data_display.py | 12 + .../ui/components/table/common.py | 28 +- .../ui/components/table/documents.py | 58 ++ src/transcription/ui/components/table/jobs.py | 14 +- .../ui/components/table/people.py | 58 ++ .../ui/components/table/sources.py | 58 ++ src/transcription/ui/components/typography.py | 17 + src/transcription/ui/components/viewers.py | 113 +++ src/transcription/ui/pages/documents_page.py | 755 ++++++++++-------- src/transcription/ui/pages/jobs_page.py | 435 +++++----- src/transcription/ui/pages/people_page.py | 530 ++++++------ src/transcription/ui/pages/sources_page.py | 179 +++-- src/transcription/ui/theme.py | 35 + 15 files changed, 1604 insertions(+), 857 deletions(-) create mode 100644 docs/ui/entities/job/~acceptance-criteria.md.saved.bak create mode 100644 src/transcription/ui/components/cards.py create mode 100644 src/transcription/ui/components/data_display.py create mode 100644 src/transcription/ui/components/table/documents.py create mode 100644 src/transcription/ui/components/table/people.py create mode 100644 src/transcription/ui/components/table/sources.py create mode 100644 src/transcription/ui/components/typography.py create mode 100644 src/transcription/ui/components/viewers.py create mode 100644 src/transcription/ui/theme.py diff --git a/docs/ui/entities/job/~acceptance-criteria.md.saved.bak b/docs/ui/entities/job/~acceptance-criteria.md.saved.bak new file mode 100644 index 0000000..6b4ac8c --- /dev/null +++ b/docs/ui/entities/job/~acceptance-criteria.md.saved.bak @@ -0,0 +1,154 @@ +# Job Acceptance Criteria + +Purpose: Define implementation-ready acceptance criteria for Job Create, Read, Update, and Delete workflows. + +Companion documents: +- docs/ui/entities/job/user-journey.md +- docs/ui/entities/job/schema-mapping.md + +## Scope + +This checklist covers: +1. Create flow +2. Read flow +3. Update flow +4. Delete flow + +This checklist does not cover: +1. provider-specific transcription internals +2. advanced workflow scheduling and queue orchestration controls +3. multi-job bulk operations + +## Create Acceptance Criteria + +### CR-1 Job creation entry +1. Given the user is on the Jobs page +2. When the user selects Create job +3. Then the user is taken to Job detail/create mode + +### CR-2 Required create values +1. document_id must be selected before submit +2. at least one source file must be uploaded before submit +3. each uploaded file creates a Source linked to the selected Document +4. each created Source is linked to the new Job through JobSource + +### CR-3 Source ordering behavior +1. Given multi-file or folder upload +2. When source records are created +3. Then page ordering follows alphabetical order of original filenames +4. Then helper text explains how filename conventions control ordering + +### CR-4 Provider/model/prompt visibility +1. provider, model, and prompt_name are visible in create flow when known +2. provider, model, and prompt_name are visible in detail flow when known +3. if values are unknown at create time, UI shows clear unknown or pending state without blocking submit + +### CR-5 Successful create outcome +1. Given valid inputs +2. When the user submits create +3. Then the Job record is created and linked to selected Document +4. Then source and JobSource records are created for uploads +5. Then job status is queued or processing based on execution timing +6. Then the user is routed to Job detail mode + +### CR-6 Create failure outcome +1. Given create validation or persistence failure +2. Then clear error feedback is shown +3. Then no false success feedback is shown +4. Then entered selections are preserved where possible +5. Then retry path remains available + +## Read Acceptance Criteria + +### RD-1 Jobs list retrieval +1. Given one or more jobs exist +2. When the user opens the Jobs page +3. Then all jobs are listed in a table or equivalent list surface + +### RD-2 Jobs list fields +1. Jobs list shows job id +2. Jobs list shows status +3. Jobs list shows created or updated timestamps +4. Jobs list shows retry_count when available +5. Jobs list provides navigation to Job detail for each row + +### RD-3 Job detail retrieval +1. Given a valid job id +2. When the user opens Job detail +3. Then job metadata for that record only is shown +4. Then document-scoped navigation links for Sources and Jobs are shown + +### RD-4 Detail execution context visibility +1. provider, model, and prompt_name are displayed when known +2. status lifecycle value is visible +3. source-level transcription and revision context is available through Source detail navigation from Job detail + +### RD-5 Missing and invalid id states +1. Given an invalid job id format +2. Then UI shows invalid job id state without crashing +3. Given a valid but nonexistent job id +4. Then UI shows job not found state without crashing + +## Update Acceptance Criteria + +### UP-1 Revision edit entry +1. Given a job detail page +2. When the user opens the page +3. Then navigation links to job-scoped Sources are available +4. Then source rows can open Source detail revision workflow + +### UP-2 Revision validation +1. revision save blocks empty trimmed text and shows warning feedback + +### UP-3 Successful revision save +1. Source detail save persists revised text and shows success feedback + +### UP-4 Revision save failure +1. Source detail save failure shows clear error feedback with retry path + +### UP-5 Job lifecycle state update visibility +1. status changes from queued to processing to terminal states are reflected in UI +2. retry_count updates are reflected when retry logic runs +3. users cannot directly edit lifecycle state fields in first release + +## Delete Acceptance Criteria + +### DL-1 Delete entry and confirmation +1. Given a job detail context +2. When the user opens job delete page +3. Then a permanent-action confirmation is shown for non-processing jobs + +### DL-2 Dependency guardrails +1. Delete is blocked while job status is processing +2. Related JobSource links are removed as part of allowed delete flow + +### DL-3 Blocked delete behavior +1. When blocked, the UI shows clear processing-state guidance +2. The user is offered navigation back to job or jobs list + +### DL-4 Successful delete +1. Given an allowed delete +2. When the user confirms delete +3. Then the job is removed and success feedback is shown +4. Then the user is returned to Jobs list + +### DL-5 Delete failure +1. Given backend failure during delete +2. Then clear error feedback is shown +3. Then the user remains in delete context with retry path + +## Cross-Criteria Quality Gates + +### QG-1 Separation of intent and implementation +1. UX intent remains in user-journey.md +2. Current versus target implementation mapping remains in schema-mapping.md + +### QG-2 Traceability +1. Each accepted behavior maps to at least one UI action or service path +2. No acceptance criterion contradicts first-release deferred items + +### QG-3 First-release constraints +1. Jobs page remains list-all with explicit Create job action +2. Job create requires Document selection and source upload +3. provider/model/prompt_name are visible to users when known +4. manual retry controls may remain deferred while status visibility is required diff --git a/src/transcription/ui/components/cards.py b/src/transcription/ui/components/cards.py new file mode 100644 index 0000000..aee8585 --- /dev/null +++ b/src/transcription/ui/components/cards.py @@ -0,0 +1,15 @@ +# transcription/ui/components/cards.py +from contextlib import contextmanager +from nicegui import ui + +@contextmanager +def archival_card(title: str | None = None, extra_classes: str = ""): + """Reusable container for Flat 2.0 Bento Grid cards.""" + with ui.card().classes( + f"w-full bg-[#F4F0E6] border border-[#6B6A65]/30 rounded-sm p-4 {extra_classes}" + ) as card: + if title: + ui.label(title.upper()).classes( + "text-xs font-bold text-[#6B6A65] tracking-wider mb-3 border-b border-[#6B6A65]/20 pb-1" + ) + yield card \ No newline at end of file diff --git a/src/transcription/ui/components/data_display.py b/src/transcription/ui/components/data_display.py new file mode 100644 index 0000000..02633ba --- /dev/null +++ b/src/transcription/ui/components/data_display.py @@ -0,0 +1,12 @@ +# transcription/ui/components/data_display.py +from nicegui import ui + +def metadata_row(label: str, value: str): + """Render a high-density, low-contrast key-value pair.""" + with ui.row().classes("justify-between w-full border-b border-[#6B6A65]/10 pb-1 text-xs"): + ui.label(label).classes("text-[#6B6A65]") + ui.label(value).classes("font-semibold text-[#333333]") + +def archival_badge(text: str): + """Standardized Aged Sepia badge.""" + return ui.badge(text, color="#E2C7A8", text_color="#333333").classes("text-[10px]") \ No newline at end of file diff --git a/src/transcription/ui/components/table/common.py b/src/transcription/ui/components/table/common.py index 6c4e309..9c93a5d 100644 --- a/src/transcription/ui/components/table/common.py +++ b/src/transcription/ui/components/table/common.py @@ -57,6 +57,8 @@ def build_table( pagination["sortBy"] = default_sort_by pagination["descending"] = default_descending + # Quasar props to enforce flat, archival styling + # Styling table headers with Library Green (#2D5A4C) and rows with subtle borders table = ( ui.table( rows=rows, @@ -64,10 +66,30 @@ def build_table( row_key="id", pagination=pagination, ) - .classes(classes) - .props('table-style="table-layout: fixed; width: 100%;"') + .classes( + f"w-full bg-[#F4F0E6] border border-[#6B6A65]/30 rounded-sm {classes}" + ) + .props( + 'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" ' + 'header-cell-class="bg-[#2D5A4C] text-white font-bold text-xs uppercase tracking-wider" ' + 'table-class="text-xs text-[#333333]"' + ) ) + + # Custom CSS rules for row hover effects matching Archival Cream + ui.add_head_html(""" + + """) + logger.info("Table built with %d rows and %d columns", len(rows), len(columns)) if on_row_click_id is not None: _bind_row_click_handler(table, on_row_click_id=on_row_click_id) - return table + return table \ No newline at end of file diff --git a/src/transcription/ui/components/table/documents.py b/src/transcription/ui/components/table/documents.py new file mode 100644 index 0000000..0970326 --- /dev/null +++ b/src/transcription/ui/components/table/documents.py @@ -0,0 +1,58 @@ +"""Documents table rendering helpers.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +from nicegui import ui + +from transcription.ui.components.cards import archival_card +from transcription.ui.components.table.common import build_table + + +@dataclass(frozen=True, slots=True) +class DocumentTableRow: + """Read model consumed by the documents table component.""" + + id: UUID + name: str + document_type: str + archive_identifier: str + created_at: str + + +def _serialize_rows(rows: Sequence[DocumentTableRow]) -> list[dict[str, Any]]: + return [ + { + "id": str(row.id), + "name": row.name, + "document_type": row.document_type or "Unspecified", + "archive_identifier": row.archive_identifier or "N/A", + "created_at": row.created_at, + } + for row in rows + ] + + +def render_documents_table(rows: Sequence[DocumentTableRow]) -> None: + """Render documents table and open detail page when clicking a row.""" + if not rows: + with archival_card(extra_classes="p-8 text-center"): + ui.label("No documents in repository yet.").classes("text-xs text-[#6B6A65]") + return + + build_table( + rows=_serialize_rows(rows), + columns=[ + {"name": "name", "label": "Document Title", "field": "name", "sortable": True, "classes": "font-serif font-semibold"}, + {"name": "document_type", "label": "Type", "field": "document_type", "sortable": True}, + {"name": "archive_identifier", "label": "Archive Ref", "field": "archive_identifier", "sortable": True, "classes": "font-mono"}, + {"name": "created_at", "label": "Created", "field": "created_at", "sortable": True}, + ], + default_sort_by="name", + classes="app-table w-full", + on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"), + ) \ No newline at end of file diff --git a/src/transcription/ui/components/table/jobs.py b/src/transcription/ui/components/table/jobs.py index 44f1c22..9ee8506 100644 --- a/src/transcription/ui/components/table/jobs.py +++ b/src/transcription/ui/components/table/jobs.py @@ -11,6 +11,7 @@ from uuid import UUID from nicegui import ui +from transcription.ui.components.cards import archival_card from .common import build_table @@ -40,7 +41,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]: return [ { "id": str(row.id), - "status": row.status, + "status": row.status.upper(), "filename": row.filename, "retry_count": row.retry_count, "date_created": _format_timestamp(row.date_created), @@ -55,15 +56,16 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]: def render_jobs_table(rows: Sequence[JobTableRow]) -> None: """Render jobs table and open a detail page when clicking a row.""" if not rows: - ui.label("No jobs yet.") + with archival_card(extra_classes="p-8 text-center"): + ui.label("No active or historical processing jobs found.").classes("text-xs text-[#6B6A65]") return build_table( rows=_serialize_rows(rows), columns=[ - {"name": "id", "label": "Job ID", "field": "id", "sortable": True}, - {"name": "status", "label": "Status", "field": "status", "sortable": True}, - {"name": "filename", "label": "Filename", "field": "filename", "sortable": True}, + {"name": "id", "label": "Job ID", "field": "id", "sortable": True, "classes": "font-mono"}, + {"name": "status", "label": "Status", "field": "status", "sortable": True, "classes": "font-semibold text-[#2D5A4C]"}, + {"name": "filename", "label": "Source Filename", "field": "filename", "sortable": True, "classes": "font-mono"}, {"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True}, {"name": "date_created", "label": "Created", "field": "date_created", "sortable": True}, {"name": "date_updated", "label": "Updated", "field": "date_updated", "sortable": True}, @@ -72,4 +74,4 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None: default_descending=True, classes="app-table w-full", on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"), - ) + ) \ No newline at end of file diff --git a/src/transcription/ui/components/table/people.py b/src/transcription/ui/components/table/people.py new file mode 100644 index 0000000..425da97 --- /dev/null +++ b/src/transcription/ui/components/table/people.py @@ -0,0 +1,58 @@ +"""People table rendering helpers.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +from nicegui import ui + +from transcription.ui.components.cards import archival_card +from transcription.ui.components.table.common import build_table + + +@dataclass(frozen=True, slots=True) +class PersonTableRow: + """Read model consumed by the people table component.""" + + id: UUID + full_name: str + display_name: str + maiden_name: str + birth_date: str + + +def _serialize_rows(rows: Sequence[PersonTableRow]) -> list[dict[str, Any]]: + return [ + { + "id": str(row.id), + "full_name": row.full_name, + "display_name": row.display_name or "Not set", + "maiden_name": row.maiden_name or "N/A", + "birth_date": row.birth_date or "Unknown", + } + for row in rows + ] + + +def render_people_table(rows: Sequence[PersonTableRow]) -> None: + """Render people table and open detail page when clicking a row.""" + if not rows: + with archival_card(extra_classes="p-8 text-center"): + ui.label("No person records found in repository.").classes("text-xs text-[#6B6A65]") + return + + build_table( + rows=_serialize_rows(rows), + columns=[ + {"name": "full_name", "label": "Full Name", "field": "full_name", "sortable": True, "classes": "font-serif font-semibold"}, + {"name": "display_name", "label": "Display Name", "field": "display_name", "sortable": True}, + {"name": "maiden_name", "label": "Maiden Name", "field": "maiden_name", "sortable": True}, + {"name": "birth_date", "label": "Birth Date", "field": "birth_date", "sortable": True}, + ], + default_sort_by="full_name", + classes="app-table w-full", + on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"), + ) \ No newline at end of file diff --git a/src/transcription/ui/components/table/sources.py b/src/transcription/ui/components/table/sources.py new file mode 100644 index 0000000..a568af9 --- /dev/null +++ b/src/transcription/ui/components/table/sources.py @@ -0,0 +1,58 @@ +"""Sources table rendering helpers.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +from nicegui import ui + +from transcription.ui.components.cards import archival_card +from transcription.ui.components.table.common import build_table + + +@dataclass(frozen=True, slots=True) +class SourceTableRow: + """Read model consumed by the sources table component.""" + + id: UUID + page_number: int + upload_name: str + filename: str + document_id: UUID + + +def _serialize_rows(rows: Sequence[SourceTableRow]) -> list[dict[str, Any]]: + return [ + { + "id": str(row.id), + "page_number": row.page_number, + "upload_name": row.upload_name, + "filename": row.filename, + "document_id": str(row.document_id), + } + for row in rows + ] + + +def render_sources_table(rows: Sequence[SourceTableRow]) -> None: + """Render sources table and open detail page when clicking a row.""" + if not rows: + with archival_card(extra_classes="p-8 text-center"): + ui.label("No source file records found.").classes("text-xs text-[#6B6A65]") + return + + build_table( + rows=_serialize_rows(rows), + columns=[ + {"name": "page_number", "label": "Page", "field": "page_number", "sortable": True}, + {"name": "upload_name", "label": "Upload Title", "field": "upload_name", "sortable": True, "classes": "font-serif"}, + {"name": "filename", "label": "Stored Filename", "field": "filename", "sortable": True, "classes": "font-mono"}, + {"name": "document_id", "label": "Document ID", "field": "document_id", "sortable": True, "classes": "font-mono"}, + ], + default_sort_by="page_number", + classes="app-table w-full", + on_row_click_id=lambda source_id: ui.navigate.to(f"/sources/{source_id}"), + ) \ No newline at end of file diff --git a/src/transcription/ui/components/typography.py b/src/transcription/ui/components/typography.py new file mode 100644 index 0000000..f05136b --- /dev/null +++ b/src/transcription/ui/components/typography.py @@ -0,0 +1,17 @@ +"""Typography helper components for Archival and Academic layouts.""" + +from nicegui import ui + +# System-wide typography styles matching the UI Design Specification +STYLE_SERIF_HEADER = "font-family: 'Georgia', 'Times New Roman', serif;" +STYLE_SANS_BODY = "font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;" + + +def page_header(title: str, subtitle: str | None = None) -> None: + """Render a standardized page title header using the Archival Serif font.""" + with ui.column().classes("gap-0 pb-2 border-b border-[#6B6A65]/30 w-full"): + ui.label(title).style( + f"{STYLE_SERIF_HEADER} font-size: 1.75rem; font-weight: 700; color: #333333;" + ) + if subtitle: + ui.label(subtitle).classes("text-xs text-[#6B6A65]") \ No newline at end of file diff --git a/src/transcription/ui/components/viewers.py b/src/transcription/ui/components/viewers.py new file mode 100644 index 0000000..d27192a --- /dev/null +++ b/src/transcription/ui/components/viewers.py @@ -0,0 +1,113 @@ +"""Media viewer components for high-contrast image inspection.""" + +from nicegui import ui + + +def dark_room_viewer( + image_path: str | None, + count_label: str = "1 Source Linked", + *, + container_height: str = "500px", +) -> None: + """Isolated high-contrast container for image inspection with pan and zoom capabilities.""" + with ui.card().classes( + "bg-[#2B2D2C] border border-[#333333] rounded-sm p-3 flex flex-col justify-between w-full" + ): + # Viewer Header Bar + with ui.row().classes("w-full justify-between items-center mb-2 text-[#FAF9F6] text-xs"): + ui.label("SOURCE MEDIA VIEWER").classes("font-mono font-bold tracking-wider") + ui.label(count_label).classes("text-[#E2C7A8]") + + # Interactive Pan/Zoom Canvas Area + if image_path: + # Container with fixed height and hidden overflow for contained panning/zooming + with ui.element("div").classes( + "relative w-full overflow-hidden border border-[#333333] bg-black/50 rounded-sm flex items-center justify-center cursor-grab active:cursor-grabbing" + ).style(f"height: {container_height};") as viewport: + + # Image element targeted by client-side pan/zoom JS + img = ( + ui.image(image_path) + .classes("max-h-full max-w-full select-none transition-transform duration-75 ease-out") + .style("transform-origin: center center;") + ) + + # Client-side JavaScript state management for smooth panning and scaling + js_pan_zoom = f""" + const viewport = getElement('{viewport.id}'); + const img = getElement('{img.id}'); + + let scale = 1; + let pointX = 0; + let pointY = 0; + let startX = 0; + let startY = 0; + let isDragging = false; + + function updateTransform() {{ + img.style.transform = `translate(${{pointX}}px, ${{pointY}}px) scale(${{scale}})`; + }} + + // Mouse Wheel Zooming + viewport.onwheel = function(e) {{ + e.preventDefault(); + const xs = (e.clientX - pointX) / scale; + const ys = (e.clientY - pointY) / scale; + + const delta = -e.deltaY; + (delta > 0) ? (scale *= 1.15) : (scale /= 1.15); + scale = Math.min(Math.max(0.5, scale), 8); // Constrain zoom level (0.5x to 8x) + + updateTransform(); + }}; + + // Mouse Drag Panning + viewport.onmousedown = function(e) {{ + e.preventDefault(); + startX = e.clientX - pointX; + startY = e.clientY - pointY; + isDragging = true; + }}; + + window.onmouseup = function() {{ + isDragging = false; + }}; + + viewport.onmousemove = function(e) {{ + if (!isDragging) return; + e.preventDefault(); + pointX = e.clientX - startX; + pointY = e.clientY - startY; + updateTransform(); + }}; + + // Global function handles for external control toolbar + window.resetZoom_{img.id} = function() {{ scale = 1; pointX = 0; pointY = 0; updateTransform(); }}; + window.zoomIn_{img.id} = function() {{ scale = Math.min(scale * 1.25, 8); updateTransform(); }}; + window.zoomOut_{img.id} = function() {{ scale = Math.max(scale / 1.25, 0.5); updateTransform(); }}; + """ + ui.run_javascript(js_pan_zoom) + + # Control Toolbar + with ui.row().classes("w-full justify-center items-center gap-2 mt-2 pt-2 border-t border-[#333333]"): + ui.button( + icon="zoom_in", + on_click=lambda: ui.run_javascript(f"window.zoomIn_{img.id}()"), + ).props("flat round dense color=white text-xs").tooltip("Zoom In") + + ui.button( + icon="zoom_out", + on_click=lambda: ui.run_javascript(f"window.zoomOut_{img.id}()"), + ).props("flat round dense color=white text-xs").tooltip("Zoom Out") + + ui.button( + icon="center_focus_strong", + on_click=lambda: ui.run_javascript(f"window.resetZoom_{img.id}()"), + ).props("flat round dense color=white text-xs").tooltip("Reset View") + + else: + # Fallback state when no image source is linked + with ui.column().classes( + "w-full flex-grow items-center justify-center border border-[#333333] bg-black/40 rounded-sm p-8" + ).style(f"min-height: {container_height};"): + ui.label("No source media available for inspection.").classes("text-[#6B6A65] text-xs italic") \ No newline at end of file diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py index 579d124..5baf9ce 100644 --- a/src/transcription/ui/pages/documents_page.py +++ b/src/transcription/ui/pages/documents_page.py @@ -17,7 +17,15 @@ from transcription.services.documents import DocumentDeleteBlockedError from transcription.services.documents import DocumentError from transcription.services.documents import DocumentService from transcription.ui.components.app_shell import render_navigation_header +from transcription.ui.components.cards import archival_card +from transcription.ui.components.data_display import archival_badge +from transcription.ui.components.data_display import metadata_row from transcription.ui.components.error_presenter import show_error +from transcription.ui.components.table.documents import DocumentTableRow +from transcription.ui.components.table.documents import render_documents_table +from transcription.ui.components.typography import page_header +from transcription.ui.components.viewers import dark_room_viewer +from transcription.ui.theme import apply_archival_theme from ...db.session import SessionFactoryDep @@ -29,267 +37,315 @@ def register_page() -> None: @ui.page("/documents/new") async def document_create_page(request: Request, session_factory: SessionFactoryDep) -> None: + apply_archival_theme() document_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/documents") - ui.label("Create document").classes("text-h5 text-weight-medium") - ui.label("Document name is required.").classes("text-body2 vibe-text-muted") + with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): + page_header("Create Document", subtitle="Document name is required.") - name_input = ui.input(label="Document name").props("outlined") - document_type_input = ui.input(label="Document type").props("outlined") - date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined type="date"') - date_raw_input = ui.input(label="Approximate date").props("outlined") - location_input = ui.input(label="Document location").props("outlined") - archive_input = ui.input(label="Archive identifier").props("outlined") - notes_input = ui.textarea(label="Notes").props("outlined autogrow") - people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold()) - author_options = ( - {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} - | {str(person.id): person.full_name for person in people} - ) + with archival_card(extra_classes="gap-3"): + name_input = ui.input(label="Document name").props("outlined bg-white").classes("w-full") + document_type_input = ui.input(label="Document type").props("outlined bg-white").classes("w-full") - def on_author_change(event) -> None: - selected = str(event.value or "").strip() - if selected == CREATE_NEW_PERSON_OPTION: - ui.navigate.to("/people/new") + with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"): + date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined bg-white type="date"') + date_raw_input = ui.input(label="Approximate date").props("outlined bg-white") - author_select = ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change).props( - "outlined" - ) - ui.link("Create new person", "/people/new").classes("text-body2") + location_input = ui.input(label="Document location").props("outlined bg-white").classes("w-full") + archive_input = ui.input(label="Archive identifier").props("outlined bg-white").classes("w-full") + notes_input = ui.textarea(label="Notes").props("outlined bg-white autogrow").classes("w-full") - return_to = request.query_params.get("return_to") + people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold()) + author_options = ( + {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} + | {str(person.id): person.full_name for person in people} + ) - async def submit_create() -> None: - candidate_name = (name_input.value or "").strip() - if not candidate_name: - ui.notify("Document name is required.", type="warning") - return + def on_author_change(event) -> None: + selected = str(event.value or "").strip() + if selected == CREATE_NEW_PERSON_OPTION: + ui.navigate.to("/people/new") - parsed_date: date | None = None - candidate_date_text = (date_input.value or "").strip() - if candidate_date_text: - try: - parsed_date = date.fromisoformat(candidate_date_text) - except ValueError: - ui.notify("Exact date must use YYYY-MM-DD.", type="warning") + author_select = ( + ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change) + .props("outlined bg-white") + .classes("w-full") + ) + ui.link("Create new person", "/people/new").classes("text-xs text-[#2D5A4C] font-medium") + + return_to = request.query_params.get("return_to") + + async def submit_create() -> None: + candidate_name = (name_input.value or "").strip() + if not candidate_name: + ui.notify("Document name is required.", type="warning") return - candidate = Document( - name=candidate_name, - document_type=(document_type_input.value or "").strip() or None, - document_date=parsed_date, - document_date_raw=(date_raw_input.value or "").strip() or None, - location_created=(location_input.value or "").strip() or None, - notes=(notes_input.value or "").strip() or None, - archive_identifier=(archive_input.value or "").strip() or None, - ) + parsed_date: date | None = None + candidate_date_text = (date_input.value or "").strip() + if candidate_date_text: + try: + parsed_date = date.fromisoformat(candidate_date_text) + except ValueError: + ui.notify("Exact date must use YYYY-MM-DD.", type="warning") + return - try: - created = await document_service.create_document(candidate) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Create failed", operation="documents.create") - return - - selected_author = (author_select.value or "").strip() - if selected_author == CREATE_NEW_PERSON_OPTION: - ui.navigate.to("/people/new") - return - if selected_author: - try: - parsed_person_id = UUID(selected_author) - except ValueError: - ui.notify("Selected author is invalid.", type="warning") - return + candidate = Document( + name=candidate_name, + document_type=(document_type_input.value or "").strip() or None, + document_date=parsed_date, + document_date_raw=(date_raw_input.value or "").strip() or None, + location_created=(location_input.value or "").strip() or None, + notes=(notes_input.value or "").strip() or None, + archive_identifier=(archive_input.value or "").strip() or None, + ) try: - await document_service.create_document_person( - DocumentPerson( - document_id=created.id, - person_id=parsed_person_id, - role=DocumentPersonRole.AUTHOR, - ) - ) + created = await document_service.create_document(candidate) except Exception as exc: # noqa: BLE001 - show_error(exc, title="Author link failed", operation="documents.create.link_author") + show_error(exc, title="Create failed", operation="documents.create") return - ui.notify("Document created", type="positive") - if return_to == "jobs_new": - ui.navigate.to(f"/jobs/new?document_id={created.id}") - return - ui.navigate.to(f"/documents/{created.id}") + selected_author = (author_select.value or "").strip() + if selected_author == CREATE_NEW_PERSON_OPTION: + ui.navigate.to("/people/new") + return + if selected_author: + try: + parsed_person_id = UUID(selected_author) + except ValueError: + ui.notify("Selected author is invalid.", type="warning") + return - with ui.row().classes("w-full items-center gap-2"): - ui.button("Save document", on_click=submit_create, icon="save").props('unelevated color="primary"') - ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back") + try: + await document_service.create_document_person( + DocumentPerson( + document_id=created.id, + person_id=parsed_person_id, + role=DocumentPersonRole.AUTHOR, + ) + ) + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Author link failed", operation="documents.create.link_author") + return + + ui.notify("Document created", type="positive") + if return_to == "jobs_new": + ui.navigate.to(f"/jobs/new?document_id={created.id}") + return + ui.navigate.to(f"/documents/{created.id}") + + with ui.row().classes("w-full items-center gap-2 mt-2"): + ui.button("Save document", on_click=submit_create, icon="save").classes("bg-[#2D5A4C] text-white") + ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back").props("flat") @ui.page("/documents") async def documents_page(session_factory: SessionFactoryDep) -> None: + apply_archival_theme() document_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/documents") - with ui.row().classes("w-full items-center justify-between"): - ui.label("Documents").classes("text-h5 text-weight-medium") - ui.button("Create new document", on_click=lambda: ui.navigate.to("/documents/new"), icon="note_add").props( - 'unelevated color="primary"' - ) + with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"): + with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"): + page_header("Archival Documents") + ui.button( + "Create new document", + on_click=lambda: ui.navigate.to("/documents/new"), + icon="note_add", + ).classes("bg-[#2D5A4C] text-white") - try: - documents = sorted( - await document_service.list_documents(), - key=lambda item: item.created_at, - reverse=True, - ) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Load failed", operation="documents.list") - return + try: + documents = sorted( + await document_service.list_documents(), + key=lambda item: item.created_at, + reverse=True, + ) + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Load failed", operation="documents.list") + return - if not documents: - ui.label("No documents yet.").classes("text-body1 vibe-text-muted") - ui.button("Create your first document", on_click=lambda: ui.navigate.to("/documents/new"), icon="note_add").props( - 'unelevated color="primary"' - ) - return - - with ui.column().classes("w-full gap-2"): - for document in documents: - with ui.card().classes("w-full"): - with ui.row().classes("w-full items-center justify-between"): - with ui.column().classes("gap-1"): - ui.label(document.name).classes("text-subtitle1 text-weight-medium") - ui.label(f"Type: {document.document_type or 'unspecified'}").classes("text-body2") - ui.button( - "Open", - on_click=lambda _=None, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"), - icon="open_in_new", - ).props("flat") + # Format documents into read-model rows for the table renderer + rows = [ + DocumentTableRow( + id=doc.id, + name=doc.name, + document_type=doc.document_type or "", + archive_identifier=doc.archive_identifier or "", + created_at=doc.created_at.strftime("%b %d, %Y"), + ) + for doc in documents + ] + render_documents_table(rows) @ui.page("/documents/{document_id}") async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None: + apply_archival_theme() document_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/documents") try: parsed_document_id = UUID(document_id) except ValueError: - ui.label("Invalid document id").classes("text-h6 text-negative") + ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") return try: document = await document_service.read_document_detail(document_id=parsed_document_id) except DocumentError: - ui.label("Document not found").classes("text-h6 text-negative") + ui.label("Document not found").classes("text-h6 text-red-800 p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="documents.read") return - ui.label(document.name).classes("text-h5 text-weight-medium") - ui.label(f"Document type: {document.document_type or 'unspecified'}").classes("text-subtitle1") author_link = next( - (item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR and item.person is not None), + ( + item + for item in document.document_people + if item.role == DocumentPersonRole.AUTHOR and item.person is not None + ), None, ) - ui.label(f"Author: {author_link.person.full_name if author_link and author_link.person is not None else 'not set'}").classes( - "text-body2" - ) - with ui.row().classes("w-full items-center gap-2"): - ui.button("Edit document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"), icon="edit").props( - 'unelevated color="primary"' - ) - ui.button( - "Delete document", - on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"), - icon="delete", - ).props("outline color=negative") + # Main Bento Grid Wrapper + with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"): + # Header Bar + with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"): + page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}") - with ui.column().classes("w-full gap-1"): - ui.label(f"Exact date: {document.document_date.isoformat() if document.document_date else 'not set'}") - ui.label(f"Approximate date: {document.document_date_raw or 'not set'}") - ui.label(f"Location created: {document.location_created or 'not set'}") - ui.label(f"Archive identifier: {document.archive_identifier or 'not set'}") - ui.label(f"Notes: {document.notes or 'not set'}") - ui.label(f"Created at (read-only): {document.created_at.isoformat()}").classes("text-body2") - ui.label(f"Updated at (read-only): {document.updated_at.isoformat()}").classes("text-body2") + with ui.row().classes("items-center gap-2"): + ui.button( + "Edit Document", + on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"), + icon="edit", + ).classes("bg-[#2D5A4C] text-white text-xs") + ui.button( + "Delete", + on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"), + icon="delete", + ).props("outlined color=negative text-xs") - ui.separator() - ui.label("Related people").classes("text-subtitle1 text-weight-medium") - if not document.document_people: - ui.label("No linked people yet.").classes("text-body2 vibe-text-muted") - else: - for link in document.document_people: - person = link.person - person_label = person.full_name if person is not None else "Unknown person" - ui.label(f"{person_label} ({link.role.value})").classes("text-body2") + # High-Density Bento Grid Layout + with ui.grid().classes("w-full grid-cols-12 gap-4"): + # ZONE 1: Source Image Viewer (Cols 1-5) + with ui.column().classes("col-span-12 lg:col-span-5"): + source_path = document.sources[0].file_path if document.sources else None + dark_room_viewer(source_path, count_label=f"{len(document.sources)} Source(s) Linked") + with ui.row().classes("w-full justify-between items-center mt-2"): + ui.button( + "View All Sources", + on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"), + icon="description", + ).props("flat dense text-xs").classes("text-[#2D5A4C]") + ui.button( + "+ Add Source", + on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), + icon="add", + ).classes("bg-[#2D5A4C] text-white text-xs") - ui.separator() - ui.label("Sources").classes("text-subtitle1 text-weight-medium") - with ui.row().classes("w-full items-center gap-2"): - ui.button( - "Sources", - on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"), - icon="description", - ).props("flat") - ui.button( - "+ Add Source", - on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), - icon="add", - ).props('unelevated color="primary"') - ui.label(f"{len(document.sources)} source(s) linked").classes("text-body2") + # ZONE 2: Metadata & Archival Attributes (Cols 6-8) + with ui.column().classes("col-span-12 lg:col-span-4 gap-4"): + with archival_card(title="Archival Metadata"): + metadata_row("Author:", author_link.person.full_name if author_link and author_link.person else "Not set") + metadata_row("Exact Date:", document.document_date.isoformat() if document.document_date else "Not set") + metadata_row("Approx. Date:", document.document_date_raw or "Not set") + metadata_row("Location Created:", document.location_created or "Not set") + metadata_row("Archive Identifier:", document.archive_identifier or "Not set") - ui.separator() - ui.label("Jobs").classes("text-subtitle1 text-weight-medium") - with ui.row().classes("w-full items-center gap-2"): - ui.button("Jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props( - "flat" - ) - ui.button( - "+ Add Job", - on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), - icon="add", - ).props('unelevated color="primary"') - ui.label(f"{len(document.jobs)} job(s) linked").classes("text-body2") + with ui.column().classes("w-full mt-2"): + ui.label("Archival Notes:").classes("text-[#6B6A65] text-xs mb-1") + ui.label(document.notes or "No notes added.").classes( + "p-2 bg-[#FAF9F6] border border-[#6B6A65]/20 rounded-sm italic text-xs text-[#333333]" + ) + + with archival_card(title="System Logistics"): + ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] text-[#6B6A65]") + ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] text-[#6B6A65]") + + # ZONE 3: Related Entities & Pipeline Jobs (Cols 9-12) + with ui.column().classes("col-span-12 lg:col-span-3 gap-4"): + with archival_card(title="Related People"): + if not document.document_people: + ui.label("No linked people yet.").classes("text-xs text-[#6B6A65] italic") + else: + with ui.column().classes("w-full gap-2"): + for link in document.document_people: + person_label = link.person.full_name if link.person is not None else "Unknown person" + with ui.row().classes( + "w-full justify-between items-center bg-[#FAF9F6] p-2 border border-[#6B6A65]/20 rounded-sm" + ): + ui.label(person_label).classes("text-xs font-semibold text-[#333333]") + archival_badge(link.role.value) + + with archival_card(title="Pipeline Jobs"): + with ui.row().classes("w-full justify-between items-center mb-2"): + ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs text-[#2D5A4C] font-bold") + + with ui.row().classes("w-full gap-2 mt-2"): + ui.button( + "View Jobs", + on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), + icon="work_history", + ).props("flat dense text-xs").classes("text-[#2D5A4C]") + ui.button( + "+ Add Job", + on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), + icon="add", + ).classes("bg-[#2D5A4C] text-white text-xs") @ui.page("/documents/{document_id}/jobs") async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None: + apply_archival_theme() document_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/documents") try: parsed_document_id = UUID(document_id) except ValueError: - ui.label("Invalid document id").classes("text-h6 text-negative") + ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") return try: document = await document_service.read_document_detail(document_id=parsed_document_id) except DocumentError: - ui.label("Document not found").classes("text-h6 text-negative") + ui.label("Document not found").classes("text-h6 text-red-800 p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="documents.jobs") return - ui.label(f"Jobs for {document.name}").classes("text-h5 text-weight-medium") - with ui.row().classes("w-full items-center gap-2"): - ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back") - ui.button("Create job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").props( - 'unelevated color="primary"' - ) + with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): + with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"): + page_header(f"Jobs for {document.name}") + with ui.row().classes("gap-2"): + ui.button( + "Back to Document", + on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), + icon="arrow_back", + ).props("flat") + ui.button( + "Create Job", + on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), + icon="add", + ).classes("bg-[#2D5A4C] text-white") - if not document.jobs: - ui.label("No jobs created yet.").classes("text-body2 vibe-text-muted") - return + if not document.jobs: + with archival_card(extra_classes="p-6 text-center"): + ui.label("No transcription processing jobs created yet.").classes("text-xs text-[#6B6A65]") + return - for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True): - with ui.card().classes("w-full"): - with ui.row().classes("w-full items-center justify-between"): - ui.label(f"{job.status.value} - {job.id}").classes("text-body2") - ui.button("Open", on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), icon="open_in_new").props( - "flat" - ) + for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True): + with archival_card(extra_classes="p-3"): + with ui.row().classes("w-full items-center justify-between"): + with ui.row().classes("items-center gap-2"): + archival_badge(job.status.value) + ui.label(f"Job ID: {job.id}").classes("text-xs font-mono text-[#333333]") + ui.button( + "Open Job", + on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), + icon="open_in_new", + ).props("flat dense").classes("text-xs text-[#2D5A4C]") @ui.page("/documents/{document_id}/sources") async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse: @@ -298,201 +354,242 @@ def register_page() -> None: @ui.page("/documents/{document_id}/edit") async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None: + apply_archival_theme() document_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/documents") try: parsed_document_id = UUID(document_id) except ValueError: - ui.label("Invalid document id").classes("text-h6 text-negative") + ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") return try: document = await document_service.read_document_detail(document_id=parsed_document_id) except DocumentError: - ui.label("Document not found").classes("text-h6 text-negative") + ui.label("Document not found").classes("text-h6 text-red-800 p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="documents.edit.read") return - ui.label("Edit document").classes("text-h5 text-weight-medium") - ui.label("Document name and document type are required.").classes("text-body2 vibe-text-muted") + with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): + page_header("Edit Document Record", subtitle="Document name and document type are required.") - name_input = ui.input(label="Document name", value=document.name).props("outlined") - document_type_input = ui.input(label="Document type", value=document.document_type or "").props("outlined") - date_input = ui.input( - label="Exact date (YYYY-MM-DD)", - value=document.document_date.isoformat() if document.document_date else "", - ).props("outlined") - date_raw_input = ui.input(label="Approximate date", value=document.document_date_raw or "").props("outlined") - location_input = ui.input(label="Document location", value=document.location_created or "").props("outlined") - archive_input = ui.input(label="Archive identifier", value=document.archive_identifier or "").props("outlined") - notes_input = ui.textarea(label="Notes", value=document.notes or "").props("outlined autogrow") - people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold()) - author_options = ( - {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} - | {str(person.id): person.full_name for person in people} - ) - existing_author = next( - (item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR), - None, - ) - author_value = str(existing_author.person_id) if existing_author is not None else "" + with archival_card(extra_classes="gap-3"): + name_input = ui.input(label="Document name", value=document.name).props("outlined bg-white").classes("w-full") + document_type_input = ( + ui.input(label="Document type", value=document.document_type or "") + .props("outlined bg-white") + .classes("w-full") + ) - def on_author_change(event) -> None: - selected = str(event.value or "").strip() - if selected == CREATE_NEW_PERSON_OPTION: - ui.navigate.to("/people/new") + with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"): + date_input = ui.input( + label="Exact date (YYYY-MM-DD)", + value=document.document_date.isoformat() if document.document_date else "", + ).props('outlined bg-white type="date"') + date_raw_input = ( + ui.input(label="Approximate date", value=document.document_date_raw or "") + .props("outlined bg-white") + ) - author_select = ui.select( - author_options, - label="Author (Person)", - value=author_value, - on_change=on_author_change, - ).props("outlined") - ui.link("Create new person", "/people/new").classes("text-body2") + location_input = ( + ui.input(label="Document location", value=document.location_created or "") + .props("outlined bg-white") + .classes("w-full") + ) + archive_input = ( + ui.input(label="Archive identifier", value=document.archive_identifier or "") + .props("outlined bg-white") + .classes("w-full") + ) + notes_input = ( + ui.textarea(label="Notes", value=document.notes or "").props("outlined bg-white autogrow").classes("w-full") + ) - async def submit_edit() -> None: - candidate_name = (name_input.value or "").strip() - candidate_type = (document_type_input.value or "").strip() - if not candidate_name: - ui.notify("Document name is required.", type="warning") - return - if not candidate_type: - ui.notify("Document type is required.", type="warning") - return + people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold()) + author_options = ( + {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} + | {str(person.id): person.full_name for person in people} + ) + existing_author = next( + (item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR), + None, + ) + author_value = str(existing_author.person_id) if existing_author is not None else "" - parsed_date: date | None = None - candidate_date_text = (date_input.value or "").strip() - if candidate_date_text: - try: - parsed_date = date.fromisoformat(candidate_date_text) - except ValueError: - ui.notify("Exact date must use YYYY-MM-DD.", type="warning") + def on_author_change(event) -> None: + selected = str(event.value or "").strip() + if selected == CREATE_NEW_PERSON_OPTION: + ui.navigate.to("/people/new") + + author_select = ( + ui.select( + author_options, + label="Author (Person)", + value=author_value, + on_change=on_author_change, + ) + .props("outlined bg-white") + .classes("w-full") + ) + ui.link("Create new person", "/people/new").classes("text-xs text-[#2D5A4C] font-medium") + + async def submit_edit() -> None: + candidate_name = (name_input.value or "").strip() + candidate_type = (document_type_input.value or "").strip() + if not candidate_name: + ui.notify("Document name is required.", type="warning") + return + if not candidate_type: + ui.notify("Document type is required.", type="warning") return - candidate = Document( - id=document.id, - name=candidate_name, - document_type=candidate_type, - document_date=parsed_date, - document_date_raw=(date_raw_input.value or "").strip() or None, - location_created=(location_input.value or "").strip() or None, - notes=(notes_input.value or "").strip() or None, - archive_identifier=(archive_input.value or "").strip() or None, - created_at=document.created_at, - updated_at=document.updated_at, - ) + parsed_date: date | None = None + candidate_date_text = (date_input.value or "").strip() + if candidate_date_text: + try: + parsed_date = date.fromisoformat(candidate_date_text) + except ValueError: + ui.notify("Exact date must use YYYY-MM-DD.", type="warning") + return - try: - await document_service.update_document(candidate) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Save failed", operation="documents.edit.save") - return + candidate = Document( + id=document.id, + name=candidate_name, + document_type=candidate_type, + document_date=parsed_date, + document_date_raw=(date_raw_input.value or "").strip() or None, + location_created=(location_input.value or "").strip() or None, + notes=(notes_input.value or "").strip() or None, + archive_identifier=(archive_input.value or "").strip() or None, + created_at=document.created_at, + updated_at=document.updated_at, + ) - selected_author = (author_select.value or "").strip() - if selected_author == CREATE_NEW_PERSON_OPTION: - ui.navigate.to("/people/new") - return - existing_author_links = [ - link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR - ] - try: - if not selected_author: - for link in existing_author_links: - await document_service.delete_document_person(link) - else: - selected_author_id = UUID(selected_author) - if not any(link.person_id == selected_author_id for link in existing_author_links): + try: + await document_service.update_document(candidate) + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Save failed", operation="documents.edit.save") + return + + selected_author = (author_select.value or "").strip() + if selected_author == CREATE_NEW_PERSON_OPTION: + ui.navigate.to("/people/new") + return + existing_author_links = [ + link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR + ] + try: + if not selected_author: for link in existing_author_links: await document_service.delete_document_person(link) - await document_service.create_document_person( - DocumentPerson( - document_id=document.id, - person_id=selected_author_id, - role=DocumentPersonRole.AUTHOR, + else: + selected_author_id = UUID(selected_author) + if not any(link.person_id == selected_author_id for link in existing_author_links): + for link in existing_author_links: + await document_service.delete_document_person(link) + await document_service.create_document_person( + DocumentPerson( + document_id=document.id, + person_id=selected_author_id, + role=DocumentPersonRole.AUTHOR, + ) ) - ) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Author update failed", operation="documents.edit.link_author") - return + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Author update failed", operation="documents.edit.link_author") + return - ui.notify("Document updated", type="positive") - ui.navigate.to(f"/documents/{document.id}") + ui.notify("Document updated", type="positive") + ui.navigate.to(f"/documents/{document.id}") - with ui.row().classes("w-full items-center gap-2"): - ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"') - ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back") + with ui.row().classes("w-full items-center gap-2 mt-2"): + ui.button("Save changes", on_click=submit_edit, icon="save").classes("bg-[#2D5A4C] text-white") + ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props( + "flat" + ) @ui.page("/documents/{document_id}/delete") async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None: + apply_archival_theme() document_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/documents") try: parsed_document_id = UUID(document_id) except ValueError: - ui.label("Invalid document id").classes("text-h6 text-negative") + ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") return try: document = await document_service.read_document_detail(document_id=parsed_document_id) except DocumentError: - ui.label("Document not found").classes("text-h6 text-negative") + ui.label("Document not found").classes("text-h6 text-red-800 p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="documents.delete.read") return - ui.label("Delete document").classes("text-h5 text-weight-medium") - ui.label(f"Document: {document.name}").classes("text-subtitle1") + with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"): + page_header("Delete Document") - has_sources = bool(document.sources) - has_jobs = bool(document.jobs) + with archival_card(extra_classes="gap-2"): + ui.label(f"Document: {document.name}").classes("text-sm font-semibold text-[#333333]") - if has_sources or has_jobs: - ui.label("Delete is blocked because related records exist.").classes("text-negative text-weight-medium") - categories: list[str] = [] - if has_sources: - categories.append("Sources") - if has_jobs: - categories.append("Jobs") - ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-body2") - ui.label("Remove related records first, then retry deletion.").classes("text-body2 vibe-text-muted") + has_sources = bool(document.sources) + has_jobs = bool(document.jobs) - with ui.row().classes("w-full items-center gap-2"): - ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back") - ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history") - return + if has_sources or has_jobs: + ui.label("Delete is blocked because related records exist.").classes("text-xs text-red-800 font-bold mt-2") + categories: list[str] = [] + if has_sources: + categories.append("Sources") + if has_jobs: + categories.append("Jobs") + ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-xs text-[#6B6A65]") + ui.label("Remove related records first, then retry deletion.").classes("text-xs text-[#6B6A65] italic") - ui.label("This action permanently deletes the document.").classes("text-negative") - - async def submit_delete() -> None: - try: - await document_service.delete_document(document) - except DocumentDeleteBlockedError as exc: - ui.notify(exc.message, type="warning") - ui.navigate.to(f"/documents/{document.id}/delete") - return - except DocumentError as exc: - if exc.category == ErrorCategory.NOT_FOUND: - ui.notify("Document not found.", type="warning") - ui.navigate.to("/documents") + with ui.row().classes("w-full items-center gap-2 mt-4"): + ui.button( + "Back to Document", + on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), + icon="arrow_back", + ).classes("bg-[#2D5A4C] text-white text-xs") + ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props( + "flat text-xs" + ) return - show_error(exc, title="Delete failed", operation="documents.delete") - return - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Delete failed", operation="documents.delete") - return - ui.notify("Document deleted", type="positive") - ui.navigate.to("/documents") + ui.label("This action permanently deletes the document.").classes("text-xs text-red-800 font-medium") - with ui.row().classes("w-full items-center gap-2"): - ui.button( - "Delete document permanently", - on_click=submit_delete, - icon="delete_forever", - ).props('unelevated color="negative"') - ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back") + async def submit_delete() -> None: + try: + await document_service.delete_document(document) + except DocumentDeleteBlockedError as exc: + ui.notify(exc.message, type="warning") + ui.navigate.to(f"/documents/{document.id}/delete") + return + except DocumentError as exc: + if exc.category == ErrorCategory.NOT_FOUND: + ui.notify("Document not found.", type="warning") + ui.navigate.to("/documents") + return + show_error(exc, title="Delete failed", operation="documents.delete") + return + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Delete failed", operation="documents.delete") + return + + ui.notify("Document deleted", type="positive") + ui.navigate.to("/documents") + + with ui.row().classes("w-full items-center gap-2 mt-2"): + ui.button( + "Delete document permanently", + on_click=submit_delete, + icon="delete_forever", + ).props("unelevated color=negative") + ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props( + "flat" + ) \ No newline at end of file diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index c664f54..48ce2dd 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -2,10 +2,10 @@ from __future__ import annotations -from fastapi import Request from pathlib import Path from uuid import UUID +from fastapi import Request from nicegui import ui from transcription.db.models import JobStatus @@ -15,8 +15,13 @@ from transcription.services.jobs import JobDeleteBlockedError from transcription.services.jobs import JobService from transcription.services.store import create_job_for_document from transcription.ui.components.app_shell import render_navigation_header +from transcription.ui.components.cards import archival_card +from transcription.ui.components.data_display import archival_badge +from transcription.ui.components.data_display import metadata_row from transcription.ui.components.error_presenter import show_error from transcription.ui.components.table.jobs import render_jobs_table +from transcription.ui.components.typography import page_header +from transcription.ui.theme import apply_archival_theme from transcription.worker import resolve_worker_notifier from ...db.session import SessionFactoryDep @@ -28,257 +33,285 @@ def register_page() -> None: # noqa: PLR0915 @ui.page("/jobs") async def jobs_page(session_factory: SessionFactoryDep) -> None: + apply_archival_theme() jobs_service = JobService(session_factory=session_factory) render_navigation_header(current_path="/jobs") - @ui.refreshable - async def render_table() -> None: - jobs = [ - JobTableRow( - id=job.id, - status=job.status.value, - filename=job.filename, - retry_count=job.retry_count, - date_created=job.date_created.isoformat(), - date_updated=job.date_updated.isoformat(), - ) - for job in await jobs_service.list_jobs() - ] - render_jobs_table(jobs) + with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"): + with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"): + page_header("Transcription Pipeline Jobs") + with ui.row().classes("items-center gap-2"): + ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes( + "bg-[#2D5A4C] text-white" + ) + ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat") - with ui.row().classes("w-full items-center gap-2"): - ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").props( - 'unelevated color="primary"' - ) - ui.button("Refresh", on_click=render_table.refresh, icon="refresh") - await render_table() + @ui.refreshable + async def render_table() -> None: + jobs = [ + JobTableRow( + id=job.id, + status=job.status.value, + filename=job.filename, + retry_count=job.retry_count, + date_created=job.date_created.isoformat(), + date_updated=job.date_updated.isoformat(), + ) + for job in await jobs_service.list_jobs() + ] + render_jobs_table(jobs) + + await render_table() @ui.page("/jobs/new") async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None: + apply_archival_theme() documents_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/jobs") - ui.label("Create job").classes("text-h5 text-weight-medium") - documents = await documents_service.list_documents() - if not documents: - ui.label("No documents available. Create a Document before creating a Job.").classes( - "text-body1 text-warning" - ) - with ui.row(): - ui.button( - "Create document", - on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"), - icon="note_add", - ).props('unelevated color="primary"') - ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back") - return + with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): + page_header("Create Processing Job", subtitle="Queue source files for AI transcription and entity processing.") - uploaded_files: list[tuple[str, bytes]] = [] - - document_options = {str(document.id): document.name for document in documents} - document_select = ui.select(document_options, label="Document").props("outlined") - requested_document_id = request.query_params.get("document_id") - if requested_document_id in document_options: - document_select.value = requested_document_id - provider_input = ui.input(label="Provider").props("outlined") - model_input = ui.input(label="Model").props("outlined") - prompt_input = ui.input(label="Prompt").props("outlined") - - ui.label("Source files").classes("text-subtitle1 text-weight-medium") - ui.label( - "Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order." - ).classes("text-body2 vibe-text-muted") - - @ui.refreshable - def render_upload_list() -> None: - if not uploaded_files: - ui.label("No files uploaded yet.").classes("text-body2 vibe-text-muted") - return - - def remove_file(index: int) -> None: - if 0 <= index < len(uploaded_files): - removed_name, _ = uploaded_files.pop(index) - ui.notify(f"Removed {removed_name}", type="info") - render_upload_list.refresh() - - def clear_files() -> None: - uploaded_files.clear() - ui.notify("Cleared queued files", type="info") - render_upload_list.refresh() - - ordered_uploads = sorted( - enumerate(uploaded_files), - key=lambda item: Path(item[1][0]).name.casefold(), - ) - - with ui.column().classes("gap-1"): - for index, (filename, _) in ordered_uploads: - with ui.row().classes("w-full items-center justify-between"): - ui.label(Path(filename).name).classes("text-body2") - ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props("flat round dense") - - with ui.row().classes("w-full justify-end"): - ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat") - - async def on_upload(event) -> None: - payload = await event.file.read() - uploaded_files.append((event.file.name, payload)) - ui.notify(f"Added {event.file.name}", type="positive") - render_upload_list.refresh() - - ui.upload( - on_upload=on_upload, - auto_upload=True, - label="Select source files or a folder", - ).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple') - - render_upload_list() - - async def submit_create() -> None: - selected_document = document_select.value - if not selected_document: - ui.notify("Document is required.", type="warning") - return - if not uploaded_files: - ui.notify("At least one source file is required.", type="warning") - return - - try: - document_id = UUID(str(selected_document)) - except ValueError: - ui.notify("Selected document id is invalid.", type="warning") - return - - try: - async with session_scope(session_factory=session_factory) as session: - result = await create_job_for_document( - document_id=document_id, - uploads=uploaded_files, - provider=(provider_input.value or None), - model=(model_input.value or None), - prompt_name=(prompt_input.value or None), - session=session, + documents = await documents_service.list_documents() + if not documents: + with archival_card(extra_classes="p-6 text-center"): + ui.label("No documents available. Create a Document before creating a Job.").classes( + "text-xs text-red-800 font-medium mb-4" ) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Create job failed", operation="jobs.create") + with ui.row().classes("justify-center gap-2"): + ui.button( + "Create document", + on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"), + icon="note_add", + ).classes("bg-[#2D5A4C] text-white") + ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat") return - resolve_worker_notifier(request.app.state).notify() - ui.notify(f"Created job {result.job_id}", type="positive") - ui.navigate.to(f"/jobs/{result.job_id}") + uploaded_files: list[tuple[str, bytes]] = [] - with ui.row().classes("w-full items-center gap-2"): - ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").props( - 'unelevated color="primary"' - ) - ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back") + with archival_card(extra_classes="gap-3"): + document_options = {str(document.id): document.name for document in documents} + document_select = ( + ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full") + ) + requested_document_id = request.query_params.get("document_id") + if requested_document_id in document_options: + document_select.value = requested_document_id + + with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"): + provider_input = ui.input(label="Provider").props("outlined bg-white") + model_input = ui.input(label="Model").props("outlined bg-white") + prompt_input = ui.input(label="Prompt").props("outlined bg-white") + + with archival_card(title="Source Files"): + ui.label( + "Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order." + ).classes("text-xs text-[#6B6A65] mb-2") + + @ui.refreshable + def render_upload_list() -> None: + if not uploaded_files: + ui.label("No files uploaded yet.").classes("text-xs text-[#6B6A65] italic") + return + + def remove_file(index: int) -> None: + if 0 <= index < len(uploaded_files): + removed_name, _ = uploaded_files.pop(index) + ui.notify(f"Removed {removed_name}", type="info") + render_upload_list.refresh() + + def clear_files() -> None: + uploaded_files.clear() + ui.notify("Cleared queued files", type="info") + render_upload_list.refresh() + + ordered_uploads = sorted( + enumerate(uploaded_files), + key=lambda item: Path(item[1][0]).name.casefold(), + ) + + with ui.column().classes("gap-1 w-full mt-2"): + for index, (filename, _) in ordered_uploads: + with ui.row().classes( + "w-full items-center justify-between bg-[#FAF9F6] p-2 border border-[#6B6A65]/20 rounded-sm" + ): + ui.label(Path(filename).name).classes("text-xs font-mono text-[#333333]") + ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props( + "flat round dense color=negative text-xs" + ) + + with ui.row().classes("w-full justify-end mt-2"): + ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat dense").classes( + "text-xs text-red-800" + ) + + async def on_upload(event) -> None: + payload = await event.file.read() + uploaded_files.append((event.file.name, payload)) + ui.notify(f"Added {event.file.name}", type="positive") + render_upload_list.refresh() + + ui.upload( + on_upload=on_upload, + auto_upload=True, + label="Select source files or a folder", + ).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple').classes("w-full") + + render_upload_list() + + async def submit_create() -> None: + selected_document = document_select.value + if not selected_document: + ui.notify("Document is required.", type="warning") + return + if not uploaded_files: + ui.notify("At least one source file is required.", type="warning") + return + + try: + document_id = UUID(str(selected_document)) + except ValueError: + ui.notify("Selected document id is invalid.", type="warning") + return + + try: + async with session_scope(session_factory=session_factory) as session: + result = await create_job_for_document( + document_id=document_id, + uploads=uploaded_files, + provider=(provider_input.value or None), + model=(model_input.value or None), + prompt_name=(prompt_input.value or None), + session=session, + ) + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Create job failed", operation="jobs.create") + return + + resolve_worker_notifier(request.app.state).notify() + ui.notify(f"Created job {result.job_id}", type="positive") + ui.navigate.to(f"/jobs/{result.job_id}") + + with ui.row().classes("w-full items-center gap-2 mt-2"): + ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").classes( + "bg-[#2D5A4C] text-white" + ) + ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat") @ui.page("/jobs/{job_id}") async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: + apply_archival_theme() jobs_service = JobService(session_factory=session_factory) render_navigation_header(current_path="/jobs") try: parsed_job_id = UUID(job_id) except ValueError: - ui.label("Invalid job id").classes("text-h6 text-negative") + ui.label("Invalid job id").classes("text-h6 text-red-800 p-4") return try: job = await jobs_service.read_job(job_id=parsed_job_id) except ValueError: - ui.label("Job not found").classes("text-h6 text-negative") + ui.label("Job not found").classes("text-h6 text-red-800 p-4") return - with ui.column().classes("w-full gap-3"): - with ui.row().classes("w-full items-center justify-between"): - ui.button(icon="arrow_back", on_click=ui.navigate.back) - match job.status: - case JobStatus.TRANSCRIBED: - ui.chip(job.status.value.upper(), color="positive", text_color="white").props("outline") - case _: - ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium") + with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): + with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"): + page_header(f"Job Record: {job.id}") + with ui.row().classes("items-center gap-2"): + archival_badge(job.status.value.upper()) + ui.button( + "Delete Job", + on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/delete"), + icon="delete", + ).props("outlined color=negative text-xs") - ui.label(f"Job {job.id}").classes("text-h6 text-weight-bold") + with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"): + with archival_card(title="Execution Logistics"): + metadata_row("Provider:", job.provider or "pending") + metadata_row("Model:", job.model or "pending") + metadata_row("Prompt:", job.prompt_name or "pending") + metadata_row("Retry Count:", str(job.retry_count)) + metadata_row("Last Updated:", job.date_updated.isoformat()) - with ui.column().classes("gap-1"): - ui.label(f"Provider: {job.provider or 'pending'}").classes("text-body2") - ui.label(f"Model: {job.model or 'pending'}").classes("text-body2") - ui.label(f"Prompt: {job.prompt_name or 'pending'}").classes("text-body2") - ui.label(f"Retry count: {job.retry_count}").classes("text-body2") - ui.label(f"Last updated: {job.date_updated.isoformat()}").classes("text-body2") - - ui.separator() - ui.label("Document Links").classes("text-subtitle1 text-weight-medium") - with ui.row().classes("w-full items-center gap-2"): - ui.button( - "Document", - on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"), - icon="description", - ).props("flat") - ui.button( - "Sources", - on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"), - icon="description", - ).props("flat") - ui.button( - "Jobs", - on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}/jobs"), - icon="work_history", - ).props("flat") + with archival_card(title="Document Links"): + ui.label("Navigate to related archival records:").classes("text-xs text-[#6B6A65] mb-3") + with ui.column().classes("w-full gap-2"): + ui.button( + "View Linked Document", + on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"), + icon="description", + ).classes("bg-[#2D5A4C] text-white text-xs w-full") + ui.button( + "View Linked Sources", + on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"), + icon="description", + ).props("flat text-xs").classes("text-[#2D5A4C] w-full") @ui.page("/jobs/{job_id}/delete") async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None: + apply_archival_theme() jobs_service = JobService(session_factory=session_factory) render_navigation_header(current_path="/jobs") try: parsed_job_id = UUID(job_id) except ValueError: - ui.label("Invalid job id").classes("text-h6 text-negative") + ui.label("Invalid job id").classes("text-h6 text-red-800 p-4") return try: job = await jobs_service.read_job(job_id=parsed_job_id) except ValueError: - ui.label("Job not found").classes("text-h6 text-negative") + ui.label("Job not found").classes("text-h6 text-red-800 p-4") return - ui.label("Delete job").classes("text-h5 text-weight-medium") - ui.label(f"Job: {job.id}").classes("text-subtitle1") + with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"): + page_header("Delete Processing Job") - if job.status == JobStatus.PROCESSING: - ui.label("Delete is blocked while the job is processing.").classes("text-negative text-weight-medium") - ui.label("Wait for processing to complete, then retry delete.").classes("text-body2 vibe-text-muted") - with ui.row().classes("w-full items-center gap-2"): - ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back") - ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history") - return + with archival_card(extra_classes="gap-2"): + ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono text-[#333333]") - ui.label("This action permanently deletes the job.").classes("text-negative") - if job.job_sources: - ui.label("Related JobSource links will be removed as part of delete.").classes("text-body2") + if job.status == JobStatus.PROCESSING: + ui.label("Delete is blocked while the job is processing.").classes( + "text-xs text-red-800 font-bold mt-2" + ) + ui.label("Wait for processing to complete, then retry delete.").classes("text-xs text-[#6B6A65] italic") + with ui.row().classes("w-full items-center gap-2 mt-4"): + ui.button( + "Back to Job", + on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), + icon="arrow_back", + ).classes("bg-[#2D5A4C] text-white text-xs") + ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props( + "flat text-xs" + ) + return - async def submit_delete() -> None: - try: - await jobs_service.delete_job_with_guardrails(job_id=job.id) - except JobDeleteBlockedError as exc: - ui.notify(exc.message, type="warning") - return - except ValueError: - ui.notify("Job not found.", type="warning") + ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium") + if job.job_sources: + ui.label("Related JobSource links will be removed as part of delete.").classes("text-xs text-[#6B6A65]") + + async def submit_delete() -> None: + try: + await jobs_service.delete_job_with_guardrails(job_id=job.id) + except JobDeleteBlockedError as exc: + ui.notify(exc.message, type="warning") + return + except ValueError: + ui.notify("Job not found.", type="warning") + ui.navigate.to("/jobs") + return + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Delete job failed", operation="jobs.delete") + return + + ui.notify("Job deleted", type="positive") ui.navigate.to("/jobs") - return - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Delete job failed", operation="jobs.delete") - return - - ui.notify("Job deleted", type="positive") - ui.navigate.to("/jobs") - - with ui.row().classes("w-full items-center gap-2"): - ui.button("Delete job permanently", on_click=submit_delete, icon="delete_forever").props( - 'unelevated color="negative"' - ) - ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back") + with ui.row().classes("w-full items-center gap-2 mt-2"): + ui.button("Delete job permanently", on_click=submit_delete, icon="delete_forever").props( + "unelevated color=negative" + ) + ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat") \ No newline at end of file diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py index 5faabc8..01a11ae 100644 --- a/src/transcription/ui/pages/people_page.py +++ b/src/transcription/ui/pages/people_page.py @@ -9,17 +9,23 @@ from uuid import UUID from fastapi import Request from nicegui import ui -from transcription.config import Settings -from transcription.config import get_settings +from transcription.config import Settings, get_settings from transcription.db.models import Person from transcription.errors import ErrorCategory -from transcription.services.documents import DocumentError -from transcription.services.documents import DocumentService -from transcription.services.documents import PersonDeleteBlockedError -from transcription.services.store import UploadError -from transcription.services.store import store_person_portrait +from transcription.services.documents import ( + DocumentError, + DocumentService, + PersonDeleteBlockedError, +) +from transcription.services.store import UploadError, store_person_portrait from transcription.ui.components.app_shell import render_navigation_header +from transcription.ui.components.cards import archival_card +from transcription.ui.components.data_display import metadata_row from transcription.ui.components.error_presenter import show_error +from transcription.ui.components.table.people import PersonTableRow, render_people_table +from transcription.ui.components.typography import page_header +from transcription.ui.components.viewers import dark_room_viewer +from transcription.ui.theme import apply_archival_theme from ...db.session import SessionFactoryDep @@ -63,8 +69,8 @@ def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Setti on_upload=on_portrait_selected, auto_upload=True, label="Choose portrait file", - ).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"') - ui.label("Portraits are stored under uploads/portraits/person.").classes("text-body2 vibe-text-muted") + ).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full") + ui.label("Portraits are stored under uploads/portraits/person.").classes("text-xs text-[#6B6A65]") def _resolve_portrait_src(path: str | None) -> str | None: @@ -95,331 +101,373 @@ def register_page() -> None: # noqa: PLR0915 @ui.page("/people") async def people_page(session_factory: SessionFactoryDep) -> None: + apply_archival_theme() people_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/people") - with ui.row().classes("w-full items-center justify-between"): - ui.label("People").classes("text-h5 text-weight-medium") - ui.button("Create new person", on_click=lambda: ui.navigate.to("/people/new"), icon="person_add").props( - 'unelevated color="primary"' - ) + with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"): + with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"): + page_header("Archival Entities: People") + ui.button( + "Create new person", + on_click=lambda: ui.navigate.to("/people/new"), + icon="person_add", + ).classes("bg-[#2D5A4C] text-white") - try: - people = sorted( - await people_service.list_people(), - key=lambda item: item.created_at, - reverse=True, - ) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Load failed", operation="people.list") - return + try: + people = sorted( + await people_service.list_people(), + key=lambda item: item.created_at, + reverse=True, + ) + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Load failed", operation="people.list") + return - if not people: - ui.label("No people yet.").classes("text-body1 vibe-text-muted") - return - - with ui.column().classes("w-full gap-2"): - for person in people: - with ui.card().classes("w-full"): - with ui.row().classes("w-full items-center justify-between"): - with ui.column().classes("gap-1"): - ui.label(person.full_name).classes("text-subtitle1 text-weight-medium") - ui.label(f"Display name: {person.display_name or 'not set'}").classes("text-body2") - ui.button( - "Open", - on_click=lambda _=None, person_id=person.id: ui.navigate.to(f"/people/{person_id}"), - icon="open_in_new", - ).props("flat") + # Format person records into read-model rows for the table renderer + rows = [ + PersonTableRow( + id=person.id, + full_name=person.full_name, + display_name=person.display_name or "", + maiden_name=person.maiden_name or "", + birth_date=person.birth_date.isoformat() if person.birth_date else "", + ) + for person in people + ] + render_people_table(rows) @ui.page("/people/new") async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None: + apply_archival_theme() people_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/people") - ui.label("Create person").classes("text-h5 text-weight-medium") - ui.label("Full name is required.").classes("text-body2 vibe-text-muted") + with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): + page_header("Create Person Record", subtitle="Full name is required.") - full_name_input = ui.input(label="Full name").props("outlined") - display_name_input = ui.input(label="Display name").props("outlined") - maiden_name_input = ui.input(label="Maiden name").props("outlined") + with archival_card(extra_classes="gap-3"): + with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"): + full_name_input = ui.input(label="Full name").props("outlined bg-white") + display_name_input = ui.input(label="Display name").props("outlined bg-white") + maiden_name_input = ui.input(label="Maiden name").props("outlined bg-white") - birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props('outlined type="date"') - birth_date_raw_input = ui.input(label="Birth date (approximate/raw)").props("outlined") - birth_place_input = ui.input(label="Birth place").props("outlined") + with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"): + birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props('outlined bg-white type="date"') + birth_date_raw_input = ui.input(label="Birth date (approximate)").props("outlined bg-white") + birth_place_input = ui.input(label="Birth place").props("outlined bg-white") - death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props('outlined type="date"') - death_date_raw_input = ui.input(label="Death date (approximate/raw)").props("outlined") - death_place_input = ui.input(label="Death place").props("outlined") + with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"): + death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props('outlined bg-white type="date"') + death_date_raw_input = ui.input(label="Death date (approximate)").props("outlined bg-white") + death_place_input = ui.input(label="Death place").props("outlined bg-white") - biography_input = ui.textarea(label="Biography").props("outlined autogrow") - portrait_path_input = ui.input(label="Portrait path").props("outlined") - _bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request)) + biography_input = ui.textarea(label="Biography").props("outlined bg-white autogrow").classes("w-full") + portrait_path_input = ui.input(label="Portrait path").props("outlined bg-white").classes("w-full") + _bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request)) - async def submit_create() -> None: - full_name = (full_name_input.value or "").strip() - if not full_name: - ui.notify("Full name is required.", type="warning") - return + async def submit_create() -> None: + full_name = (full_name_input.value or "").strip() + if not full_name: + ui.notify("Full name is required.", type="warning") + return - try: - birth_date = _parse_optional_date(birth_date_input.value, label="Birth date") - death_date = _parse_optional_date(death_date_input.value, label="Death date") - except ValueError as exc: - ui.notify(str(exc), type="warning") - return + try: + birth_date = _parse_optional_date(birth_date_input.value, label="Birth date") + death_date = _parse_optional_date(death_date_input.value, label="Death date") + except ValueError as exc: + ui.notify(str(exc), type="warning") + return - candidate = Person( - full_name=full_name, - display_name=(display_name_input.value or "").strip() or None, - maiden_name=(maiden_name_input.value or "").strip() or None, - birth_date=birth_date, - birth_date_raw=(birth_date_raw_input.value or "").strip() or None, - birth_place=(birth_place_input.value or "").strip() or None, - death_date=death_date, - death_date_raw=(death_date_raw_input.value or "").strip() or None, - death_place=(death_place_input.value or "").strip() or None, - biography=(biography_input.value or "").strip() or None, - portrait_path=(portrait_path_input.value or "").strip() or None, - ) + candidate = Person( + full_name=full_name, + display_name=(display_name_input.value or "").strip() or None, + maiden_name=(maiden_name_input.value or "").strip() or None, + birth_date=birth_date, + birth_date_raw=(birth_date_raw_input.value or "").strip() or None, + birth_place=(birth_place_input.value or "").strip() or None, + death_date=death_date, + death_date_raw=(death_date_raw_input.value or "").strip() or None, + death_place=(death_place_input.value or "").strip() or None, + biography=(biography_input.value or "").strip() or None, + portrait_path=(portrait_path_input.value or "").strip() or None, + ) - try: - created = await people_service.create_person(candidate) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Create failed", operation="people.create") - return + try: + created = await people_service.create_person(candidate) + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Create failed", operation="people.create") + return - ui.notify("Person created", type="positive") - ui.navigate.to(f"/people/{created.id}") + ui.notify("Person created", type="positive") + ui.navigate.to(f"/people/{created.id}") - with ui.row().classes("w-full items-center gap-2"): - ui.button("Save person", on_click=submit_create, icon="save").props('unelevated color="primary"') - ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back") + with ui.row().classes("w-full items-center gap-2 mt-2"): + ui.button("Save person", on_click=submit_create, icon="save").classes("bg-[#2D5A4C] text-white") + ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat") @ui.page("/people/{person_id}") async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None: + apply_archival_theme() people_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/people") try: parsed_person_id = UUID(person_id) except ValueError: - ui.label("Invalid person id").classes("text-h6 text-negative") + ui.label("Invalid person id").classes("text-h6 text-red-800 p-4") return try: person = await people_service.read_person_detail(parsed_person_id) except DocumentError: - ui.label("Person not found").classes("text-h6 text-negative") + ui.label("Person not found").classes("text-h6 text-red-800 p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="people.read") return - ui.label(person.full_name).classes("text-h5 text-weight-medium") + portrait_src = _resolve_portrait_src(person.portrait_path) - with ui.row().classes("w-full items-center gap-2"): - ui.button("Edit person", on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"), icon="edit").props( - 'unelevated color="primary"' - ) - ui.button( - "Delete person", - on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"), - icon="delete", - ).props("outline color=negative") + with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"): + with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"): + page_header(person.full_name, subtitle=f"Person ID: {person.id}") - with ui.column().classes("w-full gap-1"): - ui.label(f"Full name: {person.full_name}") - ui.label(f"Display name: {person.display_name or 'not set'}") - ui.label(f"Maiden name: {person.maiden_name or 'not set'}") - ui.label(f"Birth date: {person.birth_date.isoformat() if person.birth_date else 'not set'}") - ui.label(f"Birth date (approximate): {person.birth_date_raw or 'not set'}") - ui.label(f"Birth place: {person.birth_place or 'not set'}") - ui.label(f"Death date: {person.death_date.isoformat() if person.death_date else 'not set'}") - ui.label(f"Death date (approximate): {person.death_date_raw or 'not set'}") - ui.label(f"Death place: {person.death_place or 'not set'}") - ui.label(f"Biography: {person.biography or 'not set'}") - ui.label(f"Portrait path: {person.portrait_path or 'not set'}") - portrait_src = _resolve_portrait_src(person.portrait_path) - if portrait_src is not None: - ui.image(portrait_src).classes("w-40 rounded shadow") - else: - ui.label("No portrait image set.").classes("text-body2 vibe-text-muted") - ui.label(f"Created at (read-only): {person.created_at.isoformat()}").classes("text-body2") - ui.label(f"Updated at (read-only): {person.updated_at.isoformat()}").classes("text-body2") - - ui.separator() - ui.label("Linked documents").classes("text-subtitle1 text-weight-medium") - - if not person.document_people: - ui.label("No linked documents yet.").classes("text-body2 vibe-text-muted") - ui.label("Link this person from a Document workflow.").classes("text-body2 vibe-text-muted") - return - - with ui.column().classes("w-full gap-1"): - for link in person.document_people: - document = link.document - if document is None: - continue - with ui.row().classes("w-full items-center justify-between"): - ui.label(f"{document.name} ({link.role.value})").classes("text-body2") + with ui.row().classes("items-center gap-2"): ui.button( - "Open", - on_click=lambda _=None, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"), - icon="open_in_new", - ).props("flat") + "Edit Person", + on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"), + icon="edit", + ).classes("bg-[#2D5A4C] text-white text-xs") + ui.button( + "Delete", + on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"), + icon="delete", + ).props("outlined color=negative text-xs") + + with ui.grid().classes("w-full grid-cols-12 gap-4"): + with ui.column().classes("col-span-12 lg:col-span-4"): + dark_room_viewer(portrait_src, count_label="Portrait Media") + + with ui.column().classes("col-span-12 lg:col-span-4 gap-4"): + with archival_card(title="Biographical Record"): + metadata_row("Full Name:", person.full_name) + metadata_row("Display Name:", person.display_name or "Not set") + metadata_row("Maiden Name:", person.maiden_name or "Not set") + metadata_row("Birth Date:", person.birth_date.isoformat() if person.birth_date else "Not set") + metadata_row("Approx. Birth Date:", person.birth_date_raw or "Not set") + metadata_row("Birth Place:", person.birth_place or "Not set") + metadata_row("Death Date:", person.death_date.isoformat() if person.death_date else "Not set") + metadata_row("Approx. Death Date:", person.death_date_raw or "Not set") + metadata_row("Death Place:", person.death_place or "Not set") + + with archival_card(title="System Logistics"): + ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] text-[#6B6A65]") + ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] text-[#6B6A65]") + + with ui.column().classes("col-span-12 lg:col-span-4 gap-4"): + with archival_card(title="Biography"): + ui.label(person.biography or "No biography recorded.").classes( + "p-2 bg-[#FAF9F6] border border-[#6B6A65]/20 rounded-sm text-xs text-[#333333] italic w-full" + ) + + with archival_card(title="Linked Documents"): + if not person.document_people: + ui.label("No linked documents yet.").classes("text-xs text-[#6B6A65] italic") + ui.label("Link this person from a Document workflow.").classes("text-xs text-[#6B6A65]") + else: + with ui.column().classes("w-full gap-2"): + for link in person.document_people: + document = link.document + if document is None: + continue + with ui.row().classes( + "w-full justify-between items-center bg-[#FAF9F6] p-2 border border-[#6B6A65]/20 rounded-sm" + ): + with ui.column().classes("gap-0"): + ui.label(document.name).classes("text-xs font-semibold text-[#333333]") + ui.label(f"Role: {link.role.value}").classes("text-[10px] text-[#6B6A65]") + ui.button( + "Open", + on_click=lambda _=None, doc_id=document.id: ui.navigate.to( + f"/documents/{doc_id}" + ), + icon="open_in_new", + ).props("flat dense text-xs").classes("text-[#2D5A4C]") @ui.page("/people/{person_id}/edit") async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None: + apply_archival_theme() people_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/people") try: parsed_person_id = UUID(person_id) except ValueError: - ui.label("Invalid person id").classes("text-h6 text-negative") + ui.label("Invalid person id").classes("text-h6 text-red-800 p-4") return try: person = await people_service.read_person_detail(parsed_person_id) except DocumentError: - ui.label("Person not found").classes("text-h6 text-negative") + ui.label("Person not found").classes("text-h6 text-red-800 p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="people.edit.read") return - ui.label("Edit person").classes("text-h5 text-weight-medium") - ui.label("Full name is required.").classes("text-body2 vibe-text-muted") + with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): + page_header("Edit Person Record", subtitle="Full name is required.") - full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined") - display_name_input = ui.input(label="Display name", value=person.display_name or "").props("outlined") - maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined") + with archival_card(extra_classes="gap-3"): + with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"): + full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined bg-white") + display_name_input = ui.input(label="Display name", value=person.display_name or "").props( + "outlined bg-white" + ) + maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined bg-white") - birth_date_input = ui.input( - label="Birth date (YYYY-MM-DD)", - value=person.birth_date.isoformat() if person.birth_date else "", - ).props('outlined type="date"') - birth_date_raw_input = ui.input(label="Birth date (approximate/raw)", value=person.birth_date_raw or "").props( - "outlined" - ) - birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined") + with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"): + birth_date_input = ui.input( + label="Birth date (YYYY-MM-DD)", + value=person.birth_date.isoformat() if person.birth_date else "", + ).props('outlined bg-white type="date"') + birth_date_raw_input = ui.input( + label="Birth date (approximate)", value=person.birth_date_raw or "" + ).props("outlined bg-white") + birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined bg-white") - death_date_input = ui.input( - label="Death date (YYYY-MM-DD)", - value=person.death_date.isoformat() if person.death_date else "", - ).props('outlined type="date"') - death_date_raw_input = ui.input(label="Death date (approximate/raw)", value=person.death_date_raw or "").props( - "outlined" - ) - death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined") + with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"): + death_date_input = ui.input( + label="Death date (YYYY-MM-DD)", + value=person.death_date.isoformat() if person.death_date else "", + ).props('outlined bg-white type="date"') + death_date_raw_input = ui.input( + label="Death date (approximate)", value=person.death_date_raw or "" + ).props("outlined bg-white") + death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined bg-white") - biography_input = ui.textarea(label="Biography", value=person.biography or "").props("outlined autogrow") - portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined") - _bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request)) + biography_input = ( + ui.textarea(label="Biography", value=person.biography or "").props("outlined bg-white autogrow").classes("w-full") + ) + portrait_path_input = ( + ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined bg-white").classes("w-full") + ) + _bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request)) - async def submit_edit() -> None: - full_name = (full_name_input.value or "").strip() - if not full_name: - ui.notify("Full name is required.", type="warning") - return + async def submit_edit() -> None: + full_name = (full_name_input.value or "").strip() + if not full_name: + ui.notify("Full name is required.", type="warning") + return - try: - birth_date = _parse_optional_date(birth_date_input.value, label="Birth date") - death_date = _parse_optional_date(death_date_input.value, label="Death date") - except ValueError as exc: - ui.notify(str(exc), type="warning") - return + try: + birth_date = _parse_optional_date(birth_date_input.value, label="Birth date") + death_date = _parse_optional_date(death_date_input.value, label="Death date") + except ValueError as exc: + ui.notify(str(exc), type="warning") + return - candidate = Person( - id=person.id, - full_name=full_name, - display_name=(display_name_input.value or "").strip() or None, - maiden_name=(maiden_name_input.value or "").strip() or None, - birth_date=birth_date, - birth_date_raw=(birth_date_raw_input.value or "").strip() or None, - birth_place=(birth_place_input.value or "").strip() or None, - death_date=death_date, - death_date_raw=(death_date_raw_input.value or "").strip() or None, - death_place=(death_place_input.value or "").strip() or None, - biography=(biography_input.value or "").strip() or None, - portrait_path=(portrait_path_input.value or "").strip() or None, - metadata_=person.metadata_, - created_at=person.created_at, - updated_at=person.updated_at, - ) + candidate = Person( + id=person.id, + full_name=full_name, + display_name=(display_name_input.value or "").strip() or None, + maiden_name=(maiden_name_input.value or "").strip() or None, + birth_date=birth_date, + birth_date_raw=(birth_date_raw_input.value or "").strip() or None, + birth_place=(birth_place_input.value or "").strip() or None, + death_date=death_date, + death_date_raw=(death_date_raw_input.value or "").strip() or None, + death_place=(death_place_input.value or "").strip() or None, + biography=(biography_input.value or "").strip() or None, + portrait_path=(portrait_path_input.value or "").strip() or None, + metadata_=person.metadata_, + created_at=person.created_at, + updated_at=person.updated_at, + ) - try: - await people_service.update_person(candidate) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Save failed", operation="people.edit.save") - return + try: + await people_service.update_person(candidate) + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Save failed", operation="people.edit.save") + return - ui.notify("Person updated", type="positive") - ui.navigate.to(f"/people/{person.id}") + ui.notify("Person updated", type="positive") + ui.navigate.to(f"/people/{person.id}") - with ui.row().classes("w-full items-center gap-2"): - ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"') - ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back") + with ui.row().classes("w-full items-center gap-2 mt-2"): + ui.button("Save changes", on_click=submit_edit, icon="save").classes("bg-[#2D5A4C] text-white") + ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat") @ui.page("/people/{person_id}/delete") async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None: + apply_archival_theme() people_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/people") try: parsed_person_id = UUID(person_id) except ValueError: - ui.label("Invalid person id").classes("text-h6 text-negative") + ui.label("Invalid person id").classes("text-h6 text-red-800 p-4") return try: person = await people_service.read_person_detail(parsed_person_id) except DocumentError: - ui.label("Person not found").classes("text-h6 text-negative") + ui.label("Person not found").classes("text-h6 text-red-800 p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="people.delete.read") return - ui.label("Delete person").classes("text-h5 text-weight-medium") - ui.label(f"Person: {person.full_name}").classes("text-subtitle1") + with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"): + page_header("Delete Person Record") - if person.document_people: - ui.label("Delete is blocked because linked documents exist.").classes("text-negative text-weight-medium") - ui.label(f"Linked documents: {len(person.document_people)}").classes("text-body2") - ui.label("Remove document links first, then retry deletion.").classes("text-body2 vibe-text-muted") - with ui.row().classes("w-full items-center gap-2"): - ui.button("Back to Person", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back") - ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description") - return + with archival_card(extra_classes="gap-2"): + ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold text-[#333333]") - ui.label("This action permanently deletes the person.").classes("text-negative") - - async def submit_delete() -> None: - try: - await people_service.delete_person(person) - except PersonDeleteBlockedError as exc: - ui.notify(exc.message, type="warning") - ui.navigate.to(f"/people/{person.id}/delete") - return - except DocumentError as exc: - if exc.category == ErrorCategory.NOT_FOUND: - ui.notify("Person not found.", type="warning") - ui.navigate.to("/people") + if person.document_people: + ui.label("Delete is blocked because linked documents exist.").classes("text-xs text-red-800 font-bold mt-2") + ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs text-[#6B6A65]") + ui.label("Remove document links first, then retry deletion.").classes("text-xs text-[#6B6A65] italic") + with ui.row().classes("w-full items-center gap-2 mt-4"): + ui.button( + "Back to Person", + on_click=lambda: ui.navigate.to(f"/people/{person.id}"), + icon="arrow_back", + ).classes("bg-[#2D5A4C] text-white text-xs") + ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description").props( + "flat text-xs" + ) return - show_error(exc, title="Delete failed", operation="people.delete") - return - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Delete failed", operation="people.delete") - return - ui.notify("Person deleted", type="positive") - ui.navigate.to("/people") + ui.label("This action permanently deletes the person record.").classes("text-xs text-red-800 font-medium") - with ui.row().classes("w-full items-center gap-2"): - ui.button( - "Delete person permanently", - on_click=submit_delete, - icon="delete_forever", - ).props('unelevated color="negative"') - ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back") + async def submit_delete() -> None: + try: + await people_service.delete_person(person) + except PersonDeleteBlockedError as exc: + ui.notify(exc.message, type="warning") + ui.navigate.to(f"/people/{person.id}/delete") + return + except DocumentError as exc: + if exc.category == ErrorCategory.NOT_FOUND: + ui.notify("Person not found.", type="warning") + ui.navigate.to("/people") + return + show_error(exc, title="Delete failed", operation="people.delete") + return + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Delete failed", operation="people.delete") + return + + ui.notify("Person deleted", type="positive") + ui.navigate.to("/people") + + with ui.row().classes("w-full items-center gap-2 mt-2"): + ui.button( + "Delete person permanently", + on_click=submit_delete, + icon="delete_forever", + ).props("unelevated color=negative") + ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat") \ No newline at end of file diff --git a/src/transcription/ui/pages/sources_page.py b/src/transcription/ui/pages/sources_page.py index 787f920..b3dec9c 100644 --- a/src/transcription/ui/pages/sources_page.py +++ b/src/transcription/ui/pages/sources_page.py @@ -2,23 +2,28 @@ from __future__ import annotations -from uuid import UUID from urllib.parse import urlencode +from uuid import UUID from fastapi import Request from fastapi.responses import RedirectResponse from nicegui import ui -from transcription.db.models import JobSource -from transcription.db.models import Source -from transcription.services.documents import DocumentError -from transcription.services.documents import DocumentService +from transcription.db.models import JobSource, Source +from transcription.services.documents import DocumentError, DocumentService from transcription.services.jobs import JobService -from transcription.services.transcription import TranscriptionService -from transcription.services.transcription import TranscriptionNotFoundError +from transcription.services.transcription import ( + TranscriptionNotFoundError, + TranscriptionService, +) from transcription.ui.components.app_shell import render_navigation_header +from transcription.ui.components.cards import archival_card +from transcription.ui.components.data_display import metadata_row from transcription.ui.components.document_panzoom import render_document_panzoom from transcription.ui.components.error_presenter import show_error +from transcription.ui.components.table.sources import SourceTableRow, render_sources_table +from transcription.ui.components.typography import page_header +from transcription.ui.theme import apply_archival_theme from ...db.session import SessionFactoryDep @@ -28,6 +33,7 @@ def register_page() -> None: @ui.page("/sources") async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None: + apply_archival_theme() sources_service = TranscriptionService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory) documents_service = DocumentService(session_factory=session_factory) @@ -60,115 +66,134 @@ def register_page() -> None: else: sources = list(sorted(await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number))) except DocumentError: - ui.label("Document not found").classes("text-h6 text-negative") + ui.label("Document not found").classes("text-h6 text-red-800 p-4") return except ValueError: - ui.label("Job not found").classes("text-h6 text-negative") + ui.label("Job not found").classes("text-h6 text-red-800 p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="sources.list") return - if document_name is not None: - ui.label(f"Sources for {document_name}").classes("text-h5 text-weight-medium") - elif job_label is not None: - ui.label(f"Sources for Job {job_label}").classes("text-h5 text-weight-medium") - else: - ui.label("Sources").classes("text-h5 text-weight-medium") + with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"): + with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"): + if document_name is not None: + header_title = f"Sources: {document_name}" + elif job_label is not None: + header_title = f"Sources for Job {job_label}" + else: + header_title = "Archival Source Media" - with ui.row().classes("w-full items-center gap-2"): - if back_path is not None: - back_label = "Back to Document" if document_id is not None else "Back to Job" - ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back") + page_header(header_title) - if not sources: - ui.label("No sources added yet.").classes("text-body2 vibe-text-muted") - return - - with ui.column().classes("w-full gap-2"): - for source in sources: - detail_path = _source_detail_path(source_id=source.id, document_id=document_id, job_id=job_id) - with ui.card().classes("w-full") as card: - card.on( - "click", - lambda _=None, route=detail_path: ui.navigate.to(route), + if back_path is not None: + back_label = "Back to Document" if document_id is not None else "Back to Job" + ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes( + "bg-[#2D5A4C] text-white text-xs" ) - card.classes("cursor-pointer") - with ui.row().classes("w-full items-center justify-between"): - with ui.column().classes("gap-1"): - ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-subtitle1 text-weight-medium") - ui.label(f"Stored filename: {source.filename}").classes("text-body2") - ui.label(f"Document id: {source.document_id}").classes("text-body2 vibe-text-muted") - ui.button("Open source detail", on_click=lambda route=detail_path: ui.navigate.to(route), icon="open_in_new").props( - "flat" - ) + + # Format source records into read-model rows for the table renderer + rows = [ + SourceTableRow( + id=source.id, + page_number=source.page_number, + upload_name=source.upload_name, + filename=source.filename, + document_id=source.document_id, + ) + for source in sources + ] + render_sources_table(rows) @ui.page("/sources/{source_id}") async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None: + apply_archival_theme() sources_service = TranscriptionService(session_factory=session_factory) render_navigation_header(current_path="/sources") try: parsed_source_id = UUID(source_id) except ValueError: - ui.label("Invalid source id").classes("text-h6 text-negative") + ui.label("Invalid source id").classes("text-h6 text-red-800 p-4") return try: source = await sources_service.read_source_detail(source_id=parsed_source_id) except TranscriptionNotFoundError: - ui.label("Source not found").classes("text-h6 text-negative") + ui.label("Source not found").classes("text-h6 text-red-800 p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="sources.read") return back_path = _back_path_from_query(request.query_params) - ui.label(f"Source {source.upload_name}").classes("text-h5 text-weight-medium") - with ui.row().classes("w-full items-center gap-2"): - if back_path is not None: - back_label = "Back to Document" if "document_id" in request.query_params else "Back to Job" if "job_id" in request.query_params else "Back to Sources" - ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back") - else: - ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back") + with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"): + with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"): + page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}") - with ui.column().classes("w-full gap-3"): - render_document_panzoom(source=source) + if back_path is not None: + back_label = ( + "Back to Document" + if "document_id" in request.query_params + else "Back to Job" + if "job_id" in request.query_params + else "Back to Sources" + ) + ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes( + "bg-[#2D5A4C] text-white text-xs" + ) + else: + ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props( + "flat text-xs" + ) - with ui.column().classes("gap-1"): - ui.label(f"Page number: {source.page_number}").classes("text-body2") - ui.label(f"Upload name: {source.upload_name}").classes("text-body2") - ui.label(f"Stored filename: {source.filename}").classes("text-body2") - ui.label(f"Document id: {source.document_id}").classes("text-body2") - ui.label(f"Uploaded: {source.date_uploaded.isoformat()}").classes("text-body2") - ui.label(f"Revised: {source.date_revised.isoformat() if source.date_revised else 'not set'}").classes("text-body2") + with ui.grid().classes("w-full grid-cols-12 gap-4"): + with ui.column().classes("col-span-12 lg:col-span-7 gap-4"): + with archival_card(title="Source Inspection Viewer", extra_classes="p-2"): + render_document_panzoom(source=source) - ui.separator() - ui.label("Transcription text").classes("text-subtitle1 text-weight-medium") - ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly").classes("w-full") + with ui.column().classes("col-span-12 lg:col-span-5 gap-4"): + with archival_card(title="Source Metadata"): + metadata_row("Page Number:", str(source.page_number)) + metadata_row("Upload Name:", source.upload_name) + metadata_row("Stored Filename:", source.filename) + metadata_row("Document ID:", str(source.document_id)) + metadata_row("Date Uploaded:", source.date_uploaded.isoformat()) + metadata_row( + "Date Revised:", + source.date_revised.isoformat() if source.date_revised else "Not revised", + ) - ui.label("Revision text").classes("text-subtitle1 text-weight-medium") - revision_input = ui.textarea(label="Revision text", value=source.revised_text or "").props("outlined autogrow") - revision_input.classes("w-full") + with archival_card(title="Automated Raw Transcription"): + ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly bg-white").classes( + "w-full text-xs font-mono" + ) - async def save_revision() -> None: - candidate = (revision_input.value or "").strip() - if not candidate: - ui.notify("Revision text is required.", type="warning") - return + with archival_card(title="Curated Human Transcription"): + revision_input = ( + ui.textarea(value=source.revised_text or "") + .props("outlined autogrow bg-white") + .classes("w-full text-xs") + ) - try: - await sources_service.upsert_revision_for_source(source_id=source.id, text=candidate) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Save failed", operation="sources.save_revision") - return + async def save_revision() -> None: + candidate = (revision_input.value or "").strip() + if not candidate: + ui.notify("Revision text is required.", type="warning") + return - ui.notify("Revision saved", type="positive") - ui.navigate.to(request.url.path + _back_query(request.query_params)) + try: + await sources_service.upsert_revision_for_source(source_id=source.id, text=candidate) + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Save failed", operation="sources.save_revision") + return - with ui.row().classes("w-full items-center gap-2"): - ui.button("Save revision", on_click=save_revision, icon="save").props('unelevated color="primary"') + ui.notify("Revision saved", type="positive") + ui.navigate.to(request.url.path + _back_query(request.query_params)) + + with ui.row().classes("w-full items-center gap-2 mt-2"): + ui.button("Save Revision", on_click=save_revision, icon="save").classes("bg-[#2D5A4C] text-white text-xs") @ui.page("/documents/{document_id}/sources") async def document_sources_page(document_id: str) -> RedirectResponse: diff --git a/src/transcription/ui/theme.py b/src/transcription/ui/theme.py new file mode 100644 index 0000000..c91093c --- /dev/null +++ b/src/transcription/ui/theme.py @@ -0,0 +1,35 @@ +from nicegui import ui + +# Exact Palette & Typography Constants from UI Design Specification +THEME_COLORS = { + "primary": "#2D5A4C", # Library Green + "secondary": "#E2C7A8", # Aged Sepia + "positive": "#2D5A4C", + "accent": "#E2C7A8", + "dark": "#2B2D2C", # Matte Slate + "negative": "#A83232", +} + +# Typography Macros +STYLE_SERIF_HEADER = "font-family: 'Georgia', 'Times New Roman', serif;" +STYLE_SANS_BODY = "font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;" + + +def apply_archival_theme() -> None: + """Configures global NiceGUI theme variables and injects custom global styles.""" + ui.colors(**THEME_COLORS) + + # Global body rules: Archival Cream (#FAF9F6) background, Iron Ink (#333333) text + ui.query("body").style( + f"background-color: #FAF9F6; color: #333333; {STYLE_SANS_BODY}" + ) + + +def page_header(title: str, subtitle: str | None = None) -> None: + """Standardized page title component with Archival Serif styling.""" + with ui.column().classes("gap-0 pb-2 border-b border-[#6B6A65]/30 w-full"): + ui.label(title).style( + f"{STYLE_SERIF_HEADER} font-size: 1.75rem; font-weight: 700; color: #333333;" + ) + if subtitle: + ui.label(subtitle).classes("text-xs text-[#6B6A65]") \ No newline at end of file